This is an automated email from the ASF dual-hosted git repository.
pvillard 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 6971f29f5b NIFI-14390: Creating box processor to create, update,
extract box metadata
6971f29f5b is described below
commit 6971f29f5bee0061f37a8581e585dec643d17adc
Author: Noah Cover <[email protected]>
AuthorDate: Sun Mar 30 16:20:47 2025 -0700
NIFI-14390: Creating box processor to create, update, extract box metadata
Signed-off-by: Pierre Villard <[email protected]>
This closes #9825.
---
.../box/CreateBoxFileMetadataInstance.java | 249 +++++++++++++
.../processors/box/CreateBoxMetadataTemplate.java | 308 ++++++++++++++++
.../box/ExtractStructuredBoxFileMetadata.java | 338 ++++++++++++++++++
.../box/ListBoxFileMetadataInstances.java | 267 ++++++++++++++
.../box/UpdateBoxFileMetadataInstance.java | 328 +++++++++++++++++
.../services/org.apache.nifi.processor.Processor | 5 +
.../box/CreateBoxFileMetadataInstanceTest.java | 160 +++++++++
.../box/CreateBoxMetadataTemplateTest.java | 219 ++++++++++++
.../box/ExtractStructuredBoxFileMetadataTest.java | 303 ++++++++++++++++
.../ListBoxFileMetadataInstancesParseJsonTest.java | 138 ++++++++
.../box/ListBoxFileMetadataInstancesTest.java | 165 +++++++++
.../box/UpdateBoxFileMetadataInstanceTest.java | 388 +++++++++++++++++++++
12 files changed, 2868 insertions(+)
diff --git
a/nifi-extension-bundles/nifi-box-bundle/nifi-box-processors/src/main/java/org/apache/nifi/processors/box/CreateBoxFileMetadataInstance.java
b/nifi-extension-bundles/nifi-box-bundle/nifi-box-processors/src/main/java/org/apache/nifi/processors/box/CreateBoxFileMetadataInstance.java
new file mode 100644
index 0000000000..55667cade0
--- /dev/null
+++
b/nifi-extension-bundles/nifi-box-bundle/nifi-box-processors/src/main/java/org/apache/nifi/processors/box/CreateBoxFileMetadataInstance.java
@@ -0,0 +1,249 @@
+/*
+ * 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.box;
+
+import com.box.sdk.BoxAPIConnection;
+import com.box.sdk.BoxAPIResponseException;
+import com.box.sdk.BoxFile;
+import com.box.sdk.Metadata;
+import org.apache.nifi.annotation.behavior.InputRequirement;
+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 org.apache.nifi.annotation.lifecycle.OnScheduled;
+import org.apache.nifi.box.controllerservices.BoxClientService;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.expression.ExpressionLanguageScope;
+import org.apache.nifi.flowfile.FlowFile;
+import org.apache.nifi.processor.AbstractProcessor;
+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.processor.util.StandardValidators;
+import org.apache.nifi.serialization.RecordReader;
+import org.apache.nifi.serialization.RecordReaderFactory;
+import org.apache.nifi.serialization.record.Record;
+
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import static java.lang.String.valueOf;
+import static org.apache.nifi.processors.box.BoxFileAttributes.ERROR_CODE;
+import static org.apache.nifi.processors.box.BoxFileAttributes.ERROR_CODE_DESC;
+import static org.apache.nifi.processors.box.BoxFileAttributes.ERROR_MESSAGE;
+import static
org.apache.nifi.processors.box.BoxFileAttributes.ERROR_MESSAGE_DESC;
+
+@InputRequirement(InputRequirement.Requirement.INPUT_REQUIRED)
+@Tags({"box", "storage", "metadata", "templates", "create"})
+@CapabilityDescription("""
+ Creates a metadata instance for a Box file using a specified template
with values from the flowFile content.\s
+ The Box API requires newly created templates to be created with the
scope set as enterprise so no scope is required.\s
+ The input record should be a flat key-value object where each field
name is used as the metadata key.
+ """)
+@SeeAlso({ListBoxFileMetadataTemplates.class,
UpdateBoxFileMetadataInstance.class, ListBoxFile.class, FetchBoxFile.class})
+@WritesAttributes({
+ @WritesAttribute(attribute = "box.id", description = "The ID of the
file for which metadata was created"),
+ @WritesAttribute(attribute = "box.template.key", description = "The
template key used for metadata creation"),
+ @WritesAttribute(attribute = ERROR_CODE, description =
ERROR_CODE_DESC),
+ @WritesAttribute(attribute = ERROR_MESSAGE, description =
ERROR_MESSAGE_DESC)
+})
+public class CreateBoxFileMetadataInstance extends AbstractProcessor {
+
+ public static final PropertyDescriptor FILE_ID = new
PropertyDescriptor.Builder()
+ .name("File ID")
+ .description("The ID of the file for which to create metadata.")
+ .required(true)
+ .defaultValue("${box.id}")
+
.expressionLanguageSupported(ExpressionLanguageScope.FLOWFILE_ATTRIBUTES)
+ .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+ .build();
+
+ public static final PropertyDescriptor TEMPLATE_KEY = new
PropertyDescriptor.Builder()
+ .name("Template Key")
+ .description("The key of the metadata template to use for
creation.")
+ .required(true)
+
.expressionLanguageSupported(ExpressionLanguageScope.FLOWFILE_ATTRIBUTES)
+ .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+ .build();
+
+ public static final PropertyDescriptor RECORD_READER = new
PropertyDescriptor.Builder()
+ .name("Record Reader")
+ .description("The Record Reader to use for parsing the incoming
data")
+ .required(true)
+ .identifiesControllerService(RecordReaderFactory.class)
+ .build();
+
+ public static final Relationship REL_SUCCESS = new Relationship.Builder()
+ .name("success")
+ .description("A FlowFile is routed to this relationship after
metadata has been successfully created.")
+ .build();
+
+ public static final Relationship REL_FAILURE = new Relationship.Builder()
+ .name("failure")
+ .description("A FlowFile is routed to this relationship if an
error occurs during metadata creation.")
+ .build();
+
+ public static final Relationship REL_FILE_NOT_FOUND = new
Relationship.Builder()
+ .name("file not found")
+ .description("FlowFiles for which the specified Box file was not
found will be routed to this relationship.")
+ .build();
+
+ public static final Relationship REL_TEMPLATE_NOT_FOUND = new
Relationship.Builder()
+ .name("template not found")
+ .description("FlowFiles for which the specified metadata template
was not found will be routed to this relationship.")
+ .build();
+
+ private static final Set<Relationship> RELATIONSHIPS = Set.of(
+ REL_SUCCESS,
+ REL_FAILURE,
+ REL_FILE_NOT_FOUND,
+ REL_TEMPLATE_NOT_FOUND
+ );
+
+ private static final List<PropertyDescriptor> PROPERTY_DESCRIPTORS =
List.of(
+ BoxClientService.BOX_CLIENT_SERVICE,
+ FILE_ID,
+ TEMPLATE_KEY,
+ RECORD_READER
+ );
+
+ private volatile BoxAPIConnection boxAPIConnection;
+
+ @Override
+ protected List<PropertyDescriptor> getSupportedPropertyDescriptors() {
+ return PROPERTY_DESCRIPTORS;
+ }
+
+ @Override
+ public Set<Relationship> getRelationships() {
+ return RELATIONSHIPS;
+ }
+
+ @OnScheduled
+ public void onScheduled(final ProcessContext context) {
+ final BoxClientService boxClientService =
context.getProperty(BoxClientService.BOX_CLIENT_SERVICE)
+ .asControllerService(BoxClientService.class);
+ boxAPIConnection = boxClientService.getBoxApiConnection();
+ }
+
+ @Override
+ public void onTrigger(final ProcessContext context, final ProcessSession
session) throws ProcessException {
+ FlowFile flowFile = session.get();
+ if (flowFile == null) {
+ return;
+ }
+
+ final String fileId =
context.getProperty(FILE_ID).evaluateAttributeExpressions(flowFile).getValue();
+ final String templateKey =
context.getProperty(TEMPLATE_KEY).evaluateAttributeExpressions(flowFile).getValue();
+ final RecordReaderFactory recordReaderFactory =
context.getProperty(RECORD_READER).asControllerService(RecordReaderFactory.class);
+
+ try (final InputStream inputStream = session.read(flowFile);
+ final RecordReader recordReader =
recordReaderFactory.createRecordReader(flowFile, inputStream, getLogger())) {
+
+ final Metadata metadata = new Metadata();
+ final List<String> errors = new ArrayList<>();
+
+ Record record = recordReader.nextRecord();
+ if (record != null) {
+ processRecord(record, metadata, errors);
+ } else {
+ errors.add("No records found in input");
+ }
+
+ if (!errors.isEmpty()) {
+ flowFile = session.putAttribute(flowFile, ERROR_MESSAGE,
String.join(", ", errors));
+ session.transfer(flowFile, REL_FAILURE);
+ return;
+ }
+
+ if (metadata.getOperations().isEmpty()) {
+ flowFile = session.putAttribute(flowFile, ERROR_MESSAGE, "No
valid metadata key-value pairs found in the input");
+ session.transfer(flowFile, REL_FAILURE);
+ return;
+ }
+
+ final BoxFile boxFile = getBoxFile(fileId);
+ boxFile.createMetadata(templateKey, metadata);
+ } catch (final BoxAPIResponseException e) {
+ flowFile = session.putAttribute(flowFile, ERROR_CODE,
valueOf(e.getResponseCode()));
+ flowFile = session.putAttribute(flowFile, ERROR_MESSAGE,
e.getMessage());
+ if (e.getResponseCode() == 404) {
+ final String errorBody = e.getResponse();
+ if (errorBody != null &&
errorBody.toLowerCase().contains("specified metadata template not found")) {
+ getLogger().warn("Box metadata template with key {} was
not found.", templateKey);
+ session.transfer(flowFile, REL_TEMPLATE_NOT_FOUND);
+ } else {
+ getLogger().warn("Box file with ID {} was not found.",
fileId);
+ session.transfer(flowFile, REL_FILE_NOT_FOUND);
+ }
+ } else {
+ getLogger().error("Couldn't create metadata for file with id
[{}]", fileId, e);
+ session.transfer(flowFile, REL_FAILURE);
+ }
+ return;
+ } catch (Exception e) {
+ getLogger().error("Error processing metadata creation for Box file
[{}]", fileId, e);
+ flowFile = session.putAttribute(flowFile, ERROR_MESSAGE,
e.getMessage());
+ session.transfer(flowFile, REL_FAILURE);
+ return;
+ }
+
+ final Map<String, String> attributes = Map.of(
+ "box.id", fileId,
+ "box.template.key", templateKey);
+ flowFile = session.putAllAttributes(flowFile, attributes);
+
+ session.getProvenanceReporter().create(flowFile,
"%s%s/metadata/enterprise/%s".formatted(BoxFileUtils.BOX_URL, fileId,
templateKey));
+ session.transfer(flowFile, REL_SUCCESS);
+ }
+
+ private void processRecord(Record record, Metadata metadata, List<String>
errors) {
+ if (record == null) {
+ errors.add("No record found in input");
+ return;
+ }
+
+ List<String> fieldNames = record.getSchema().getFieldNames();
+
+ if (fieldNames.isEmpty()) {
+ errors.add("Record has no fields");
+ return;
+ }
+
+ for (String fieldName : fieldNames) {
+ Object valueObj = record.getValue(fieldName);
+ String value = valueObj != null ? valueObj.toString() : null;
+ metadata.add("/" + fieldName, value);
+ }
+ }
+
+ /**
+ * Returns a BoxFile object for the given file ID.
+ *
+ * @param fileId The ID of the file.
+ * @return A BoxFile object for the given file ID.
+ */
+ BoxFile getBoxFile(final String fileId) {
+ return new BoxFile(boxAPIConnection, fileId);
+ }
+}
diff --git
a/nifi-extension-bundles/nifi-box-bundle/nifi-box-processors/src/main/java/org/apache/nifi/processors/box/CreateBoxMetadataTemplate.java
b/nifi-extension-bundles/nifi-box-bundle/nifi-box-processors/src/main/java/org/apache/nifi/processors/box/CreateBoxMetadataTemplate.java
new file mode 100644
index 0000000000..d538e6a3ee
--- /dev/null
+++
b/nifi-extension-bundles/nifi-box-bundle/nifi-box-processors/src/main/java/org/apache/nifi/processors/box/CreateBoxMetadataTemplate.java
@@ -0,0 +1,308 @@
+/*
+ * 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.box;
+
+import com.box.sdk.BoxAPIConnection;
+import com.box.sdk.BoxAPIResponseException;
+import com.box.sdk.MetadataTemplate;
+import org.apache.nifi.annotation.behavior.InputRequirement;
+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 org.apache.nifi.annotation.lifecycle.OnScheduled;
+import org.apache.nifi.box.controllerservices.BoxClientService;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.expression.ExpressionLanguageScope;
+import org.apache.nifi.flowfile.FlowFile;
+import org.apache.nifi.processor.AbstractProcessor;
+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.processor.util.StandardValidators;
+import org.apache.nifi.serialization.RecordReader;
+import org.apache.nifi.serialization.RecordReaderFactory;
+import org.apache.nifi.serialization.record.Record;
+
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import static java.lang.String.valueOf;
+import static org.apache.nifi.processors.box.BoxFileAttributes.ERROR_CODE;
+import static org.apache.nifi.processors.box.BoxFileAttributes.ERROR_CODE_DESC;
+import static org.apache.nifi.processors.box.BoxFileAttributes.ERROR_MESSAGE;
+import static
org.apache.nifi.processors.box.BoxFileAttributes.ERROR_MESSAGE_DESC;
+
+@InputRequirement(InputRequirement.Requirement.INPUT_REQUIRED)
+@Tags({"box", "storage", "metadata", "templates", "create"})
+@CapabilityDescription("""
+ Creates a Box metadata template using field specifications from the
flowFile content. Expects a schema with fields:\s
+ "'type' (required), 'key' (required), 'displayName' (optional),
'description' (optional), 'hidden' (optional, boolean).
+ """)
+@SeeAlso({ListBoxFileMetadataTemplates.class,
UpdateBoxFileMetadataInstance.class})
+@WritesAttributes({
+ @WritesAttribute(attribute = "box.template.name", description = "The
template name that was created"),
+ @WritesAttribute(attribute = "box.template.key", description = "The
template key that was created"),
+ @WritesAttribute(attribute = "box.template.scope", description = "The
template scope."),
+ @WritesAttribute(attribute = "box.template.fields.count", description
= "Number of fields created for the template"),
+ @WritesAttribute(attribute = ERROR_CODE, description =
ERROR_CODE_DESC),
+ @WritesAttribute(attribute = ERROR_MESSAGE, description =
ERROR_MESSAGE_DESC)
+})
+public class CreateBoxMetadataTemplate extends AbstractProcessor {
+
+ public static final String SCOPE_ENTERPRISE = "enterprise";
+
+ private static final Set<String> VALID_FIELD_TYPES = new
HashSet<>(Arrays.asList("string", "float", "date", "enum", "multiSelect"));
+
+ public static final PropertyDescriptor TEMPLATE_NAME = new
PropertyDescriptor.Builder()
+ .name("Template Name")
+ .description("The display name of the metadata template to
create.")
+ .required(true)
+
.expressionLanguageSupported(ExpressionLanguageScope.FLOWFILE_ATTRIBUTES)
+ .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+ .build();
+
+ public static final PropertyDescriptor TEMPLATE_KEY = new
PropertyDescriptor.Builder()
+ .name("Template Key")
+ .description("The key of the metadata template to create (used for
API calls).")
+ .required(true)
+
.expressionLanguageSupported(ExpressionLanguageScope.FLOWFILE_ATTRIBUTES)
+ .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+ .build();
+
+ public static final PropertyDescriptor HIDDEN = new
PropertyDescriptor.Builder()
+ .name("Hidden")
+ .description("Whether the template should be hidden in the Box
UI.")
+ .required(true)
+ .allowableValues("true", "false")
+ .defaultValue("false")
+
.expressionLanguageSupported(ExpressionLanguageScope.FLOWFILE_ATTRIBUTES)
+ .build();
+
+ public static final PropertyDescriptor RECORD_READER = new
PropertyDescriptor.Builder()
+ .name("Record Reader")
+ .description("The Record Reader to use for parsing the incoming
data")
+ .required(true)
+ .identifiesControllerService(RecordReaderFactory.class)
+ .build();
+
+ public static final Relationship REL_SUCCESS = new Relationship.Builder()
+ .name("success")
+ .description("A FlowFile is routed to this relationship after a
template has been successfully created.")
+ .build();
+
+ public static final Relationship REL_FAILURE = new Relationship.Builder()
+ .name("failure")
+ .description("A FlowFile is routed to this relationship if an
error occurs during template creation.")
+ .build();
+
+ private static final Set<Relationship> RELATIONSHIPS = Set.of(
+ REL_SUCCESS,
+ REL_FAILURE
+ );
+
+ private static final List<PropertyDescriptor> PROPERTY_DESCRIPTORS =
List.of(
+ BoxClientService.BOX_CLIENT_SERVICE,
+ TEMPLATE_NAME,
+ TEMPLATE_KEY,
+ HIDDEN,
+ RECORD_READER
+ );
+
+ private volatile BoxAPIConnection boxAPIConnection;
+
+ @Override
+ protected List<PropertyDescriptor> getSupportedPropertyDescriptors() {
+ return PROPERTY_DESCRIPTORS;
+ }
+
+ @Override
+ public Set<Relationship> getRelationships() {
+ return RELATIONSHIPS;
+ }
+
+ @OnScheduled
+ public void onScheduled(final ProcessContext context) {
+ boxAPIConnection = getBoxAPIConnection(context);
+ }
+
+ protected BoxAPIConnection getBoxAPIConnection(final ProcessContext
context) {
+ final BoxClientService boxClientService =
context.getProperty(BoxClientService.BOX_CLIENT_SERVICE)
+ .asControllerService(BoxClientService.class);
+ return boxClientService.getBoxApiConnection();
+ }
+
+ @Override
+ public void onTrigger(final ProcessContext context, final ProcessSession
session) throws ProcessException {
+ FlowFile flowFile = session.get();
+ if (flowFile == null) {
+ return;
+ }
+
+ final String templateName =
context.getProperty(TEMPLATE_NAME).evaluateAttributeExpressions(flowFile).getValue();
+ final String templateKey =
context.getProperty(TEMPLATE_KEY).evaluateAttributeExpressions(flowFile).getValue();
+ final boolean hidden =
Boolean.parseBoolean(context.getProperty(HIDDEN).evaluateAttributeExpressions(flowFile).getValue());
+ final RecordReaderFactory recordReaderFactory =
context.getProperty(RECORD_READER).asControllerService(RecordReaderFactory.class);
+
+ try (final InputStream inputStream = session.read(flowFile);
+ final RecordReader recordReader =
recordReaderFactory.createRecordReader(flowFile, inputStream, getLogger())) {
+
+ final List<MetadataTemplate.Field> fields = new ArrayList<>();
+ final List<String> errors = new ArrayList<>();
+ final Set<String> processedKeys = new HashSet<>();
+
+ Record record;
+ try {
+ while ((record = recordReader.nextRecord()) != null) {
+ processRecord(record, fields, processedKeys, errors);
+ }
+ } catch (final Exception e) {
+ getLogger().error("Error processing record: {}",
e.getMessage(), e);
+ errors.add("Error processing record: " + e.getMessage());
+ }
+
+ if (!errors.isEmpty()) {
+ flowFile = session.putAttribute(flowFile, ERROR_MESSAGE,
String.join(", ", errors));
+ session.transfer(flowFile, REL_FAILURE);
+ return;
+ }
+
+ if (fields.isEmpty()) {
+ flowFile = session.putAttribute(flowFile, ERROR_MESSAGE, "No
valid metadata field specifications found in the input");
+ session.transfer(flowFile, REL_FAILURE);
+ return;
+ }
+
+ createBoxMetadataTemplate(
+ boxAPIConnection,
+ templateKey,
+ templateName,
+ hidden,
+ fields);
+ final Map<String, String> attributes = new HashMap<>();
+ attributes.put("box.template.name", templateName);
+ attributes.put("box.template.key", templateKey);
+ attributes.put("box.template.scope", SCOPE_ENTERPRISE);
+ attributes.put("box.template.fields.count",
String.valueOf(fields.size()));
+ flowFile = session.putAllAttributes(flowFile, attributes);
+
+ session.getProvenanceReporter().create(flowFile, "Created Box
metadata template: " + templateName);
+ session.transfer(flowFile, REL_SUCCESS);
+
+ } catch (final BoxAPIResponseException e) {
+ flowFile = session.putAttribute(flowFile, ERROR_CODE,
valueOf(e.getResponseCode()));
+ flowFile = session.putAttribute(flowFile, ERROR_MESSAGE,
e.getMessage());
+ getLogger().error("Couldn't create metadata template with name
[{}]", templateName, e);
+ session.transfer(flowFile, REL_FAILURE);
+ } catch (final Exception e) {
+ getLogger().error("Error processing metadata template creation",
e);
+ flowFile = session.putAttribute(flowFile, ERROR_MESSAGE,
e.getMessage());
+ session.transfer(flowFile, REL_FAILURE);
+ }
+ }
+
+ private void processRecord(final Record record,
+ final List<MetadataTemplate.Field> fields,
+ final Set<String> processedKeys,
+ final List<String> errors) {
+ // Extract and validate key (required)
+ final Object keyObj = record.getValue("key");
+ if (keyObj == null) {
+ errors.add("Record is missing a key field");
+ return;
+ }
+ final String key = keyObj.toString();
+
+ if (processedKeys.contains(key)) {
+ errors.add("Duplicate key '" + key + "' found in record, failing
template creation");
+ return;
+ }
+
+ // Extract and validate type (required)
+ final Object typeObj = record.getValue("type");
+ if (typeObj == null) {
+ errors.add("Record with key '" + key + "' is missing a type
field");
+ return;
+ }
+ final String normalizedType = typeObj.toString().toLowerCase();
+
+ if (!VALID_FIELD_TYPES.contains(normalizedType)) {
+ errors.add("Record with key '" + key + "' has an invalid type: '"
+ normalizedType +
+ "'. Valid types are: " + String.join(", ",
VALID_FIELD_TYPES));
+ return;
+ }
+
+ final MetadataTemplate.Field metadataField = new
MetadataTemplate.Field();
+ metadataField.setKey(key);
+ metadataField.setType(normalizedType);
+
+ final Object displayNameObj = record.getValue("displayName");
+ if (displayNameObj != null) {
+ metadataField.setDisplayName(displayNameObj.toString());
+ }
+
+ final Object hiddenObj = record.getValue("hidden");
+ if (hiddenObj != null) {
+
metadataField.setIsHidden(Boolean.parseBoolean(hiddenObj.toString()));
+ }
+
+ final Object descriptionObj = record.getValue("description");
+ if (descriptionObj != null) {
+ metadataField.setDescription(descriptionObj.toString());
+ }
+
+ if ("enum".equals(normalizedType) ||
"multiSelect".equals(normalizedType)) {
+ final Object optionsObj = record.getValue("options");
+ if (optionsObj instanceof List<?> optionsList) {
+ final List<String> options = optionsList.stream()
+ .map(obj -> {
+ if (obj == null) {
+ throw new IllegalArgumentException("Null
option value found for field '" + key + "'");
+ }
+ return obj.toString();
+ })
+ .toList();
+ metadataField.setOptions(options);
+ }
+ }
+
+ fields.add(metadataField);
+ processedKeys.add(key);
+ }
+
+ protected void createBoxMetadataTemplate(final BoxAPIConnection
boxAPIConnection,
+ final String templateKey,
+ final String templateName,
+ final boolean isHidden,
+ final
List<MetadataTemplate.Field> fields) {
+ MetadataTemplate.createMetadataTemplate(
+ boxAPIConnection,
+ CreateBoxMetadataTemplate.SCOPE_ENTERPRISE,
+ templateKey,
+ templateName,
+ isHidden,
+ fields);
+ }
+}
diff --git
a/nifi-extension-bundles/nifi-box-bundle/nifi-box-processors/src/main/java/org/apache/nifi/processors/box/ExtractStructuredBoxFileMetadata.java
b/nifi-extension-bundles/nifi-box-bundle/nifi-box-processors/src/main/java/org/apache/nifi/processors/box/ExtractStructuredBoxFileMetadata.java
new file mode 100644
index 0000000000..8b377bae66
--- /dev/null
+++
b/nifi-extension-bundles/nifi-box-bundle/nifi-box-processors/src/main/java/org/apache/nifi/processors/box/ExtractStructuredBoxFileMetadata.java
@@ -0,0 +1,338 @@
+/*
+ * 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.box;
+
+import com.box.sdk.BoxAI;
+import com.box.sdk.BoxAIExtractField;
+import com.box.sdk.BoxAIExtractFieldOption;
+import com.box.sdk.BoxAIExtractMetadataTemplate;
+import com.box.sdk.BoxAIExtractStructuredResponse;
+import com.box.sdk.BoxAIItem;
+import com.box.sdk.BoxAPIConnection;
+import com.box.sdk.BoxAPIResponseException;
+import org.apache.nifi.annotation.behavior.InputRequirement;
+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 org.apache.nifi.annotation.lifecycle.OnScheduled;
+import org.apache.nifi.box.controllerservices.BoxClientService;
+import org.apache.nifi.components.DescribedValue;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.expression.ExpressionLanguageScope;
+import org.apache.nifi.flowfile.FlowFile;
+import org.apache.nifi.flowfile.attributes.CoreAttributes;
+import org.apache.nifi.processor.AbstractProcessor;
+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.processor.util.StandardValidators;
+import org.apache.nifi.serialization.MalformedRecordException;
+import org.apache.nifi.serialization.RecordReader;
+import org.apache.nifi.serialization.RecordReaderFactory;
+import org.apache.nifi.serialization.record.Record;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import static java.lang.String.valueOf;
+import static org.apache.nifi.processors.box.BoxFileAttributes.ERROR_CODE;
+import static org.apache.nifi.processors.box.BoxFileAttributes.ERROR_CODE_DESC;
+import static org.apache.nifi.processors.box.BoxFileAttributes.ERROR_MESSAGE;
+import static
org.apache.nifi.processors.box.BoxFileAttributes.ERROR_MESSAGE_DESC;
+
+@InputRequirement(InputRequirement.Requirement.INPUT_REQUIRED)
+@Tags({"box", "storage", "metadata", "ai", "extract"})
+@CapabilityDescription("Extracts metadata from a Box file using Box AI. The
extraction can use either a template or a list of fields. " +
+ "The extracted metadata is written to the FlowFile content as JSON.")
+@SeeAlso({ListBoxFileMetadataTemplates.class, ListBoxFile.class,
FetchBoxFile.class, UpdateBoxFileMetadataInstance.class})
+@WritesAttributes({
+ @WritesAttribute(attribute = "box.id", description = "The ID of the
file from which metadata was extracted"),
+ @WritesAttribute(attribute = "box.ai.template.key", description = "The
template key used for extraction (when using TEMPLATE extraction method)"),
+ @WritesAttribute(attribute = "box.ai.extraction.method", description =
"The extraction method used (TEMPLATE or FIELDS)"),
+ @WritesAttribute(attribute = "box.ai.completion.reason", description =
"The completion reason from the AI extraction"),
+ @WritesAttribute(attribute = "mime.type", description = "Set to
'application/json' for the JSON content"),
+ @WritesAttribute(attribute = ERROR_CODE, description =
ERROR_CODE_DESC),
+ @WritesAttribute(attribute = ERROR_MESSAGE, description =
ERROR_MESSAGE_DESC)
+})
+public class ExtractStructuredBoxFileMetadata extends AbstractProcessor {
+
+ public static final PropertyDescriptor FILE_ID = new
PropertyDescriptor.Builder()
+ .name("File ID")
+ .description("The ID of the file from which to extract metadata.")
+ .required(true)
+ .defaultValue("${box.id}")
+
.expressionLanguageSupported(ExpressionLanguageScope.FLOWFILE_ATTRIBUTES)
+ .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+ .build();
+
+ public static final PropertyDescriptor EXTRACTION_METHOD = new
PropertyDescriptor.Builder()
+ .name("Extraction Method")
+ .description("The method to use for extracting metadata. TEMPLATE
uses a Box metadata template for extraction. " +
+ "FIELDS uses a JSON schema of fields (read from FlowFile
content) for extraction.")
+ .required(true)
+ .allowableValues(ExtractionMethod.TEMPLATE,
ExtractionMethod.FIELDS)
+ .defaultValue(ExtractionMethod.TEMPLATE.getValue())
+ .build();
+
+ public static final PropertyDescriptor TEMPLATE_KEY = new
PropertyDescriptor.Builder()
+ .name("Template Key")
+ .description("The key of the metadata template to use for
extraction. Required when Extraction Method is TEMPLATE.")
+ .required(true)
+
.expressionLanguageSupported(ExpressionLanguageScope.FLOWFILE_ATTRIBUTES)
+ .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+ .dependsOn(EXTRACTION_METHOD, ExtractionMethod.TEMPLATE.getValue())
+ .build();
+
+ public static final PropertyDescriptor RECORD_READER = new
PropertyDescriptor.Builder()
+ .name("Record Reader")
+ .description("The Record Reader to use for parsing the incoming
data. " +
+ "Required when Extraction Method is FIELDS.")
+ .required(true)
+ .identifiesControllerService(RecordReaderFactory.class)
+ .dependsOn(EXTRACTION_METHOD, ExtractionMethod.FIELDS.getValue())
+ .build();
+
+ public static final Relationship REL_SUCCESS = new Relationship.Builder()
+ .name("success")
+ .description("A FlowFile is routed to this relationship after
metadata has been successfully extracted.")
+ .build();
+
+ public static final Relationship REL_FAILURE = new Relationship.Builder()
+ .name("failure")
+ .description("A FlowFile is routed to this relationship if an
error occurs during metadata extraction.")
+ .build();
+
+ public static final Relationship REL_FILE_NOT_FOUND = new
Relationship.Builder()
+ .name("file not found")
+ .description("FlowFiles for which the specified Box file was not
found will be routed to this relationship.")
+ .build();
+
+ public static final Relationship REL_TEMPLATE_NOT_FOUND = new
Relationship.Builder()
+ .name("template not found")
+ .description("FlowFiles for which the specified metadata template
was not found will be routed to this relationship.")
+ .build();
+
+ private static final Set<Relationship> RELATIONSHIPS = Set.of(
+ REL_SUCCESS,
+ REL_FAILURE,
+ REL_FILE_NOT_FOUND,
+ REL_TEMPLATE_NOT_FOUND
+ );
+
+ private static final List<PropertyDescriptor> PROPERTY_DESCRIPTORS =
List.of(
+ BoxClientService.BOX_CLIENT_SERVICE,
+ FILE_ID,
+ EXTRACTION_METHOD,
+ TEMPLATE_KEY,
+ RECORD_READER
+ );
+
+ private static final String SCOPE = "enterprise";
+
+ private volatile BoxAPIConnection boxAPIConnection;
+
+ @Override
+ protected List<PropertyDescriptor> getSupportedPropertyDescriptors() {
+ return PROPERTY_DESCRIPTORS;
+ }
+
+ @Override
+ public Set<Relationship> getRelationships() {
+ return RELATIONSHIPS;
+ }
+
+ @OnScheduled
+ public void onScheduled(final ProcessContext context) {
+ final BoxClientService boxClientService =
context.getProperty(BoxClientService.BOX_CLIENT_SERVICE)
+ .asControllerService(BoxClientService.class);
+ boxAPIConnection = boxClientService.getBoxApiConnection();
+ }
+
+ @Override
+ public void onTrigger(final ProcessContext context, final ProcessSession
session) throws ProcessException {
+ FlowFile flowFile = session.get();
+ if (flowFile == null) {
+ return;
+ }
+
+ final String fileId =
context.getProperty(FILE_ID).evaluateAttributeExpressions(flowFile).getValue();
+ final ExtractionMethod extractionMethod =
ExtractionMethod.valueOf(context.getProperty(EXTRACTION_METHOD).getValue());
+
+ try {
+ final BoxAIExtractStructuredResponse result;
+ final Map<String, String> attributes = new HashMap<>();
+ attributes.put("box.id", fileId);
+ attributes.put("box.ai.extraction.method",
extractionMethod.name());
+
+ result = switch (extractionMethod) {
+ case TEMPLATE -> {
+ final String templateKey =
context.getProperty(TEMPLATE_KEY)
+ .evaluateAttributeExpressions(flowFile).getValue();
+ attributes.put("box.ai.template.key", templateKey);
+ yield
getBoxAIExtractStructuredResponseWithTemplate(templateKey, fileId);
+ }
+ case FIELDS -> {
+ final RecordReaderFactory recordReaderFactory =
context.getProperty(RECORD_READER)
+ .asControllerService(RecordReaderFactory.class);
+ try (final InputStream inputStream =
session.read(flowFile);
+ final RecordReader recordReader =
recordReaderFactory.createRecordReader(flowFile, inputStream, getLogger())) {
+ yield
getBoxAIExtractStructuredResponseWithFields(recordReader, fileId);
+ }
+ }
+ };
+
+ final String completionReason = result.getCompletionReason();
+ if (completionReason != null) {
+ attributes.put("box.ai.completion.reason", completionReason);
+ }
+
+ flowFile = session.putAllAttributes(flowFile, attributes);
+
+ try (final OutputStream out = session.write(flowFile)) {
+ if (result.getAnswer() != null) {
+ out.write(result.getAnswer().toString().getBytes());
+ } else {
+ flowFile = session.putAttribute(flowFile, ERROR_MESSAGE,
"No answer present in the AI extraction result");
+ session.transfer(flowFile, REL_FAILURE);
+ return;
+ }
+ } catch (final IOException e) {
+ getLogger().error("Error writing Box AI metadata extraction
answer to FlowFile", e);
+ flowFile = session.putAttribute(flowFile, ERROR_MESSAGE,
e.getMessage());
+ session.transfer(flowFile, REL_FAILURE);
+ return;
+ }
+ flowFile = session.putAttribute(flowFile,
CoreAttributes.MIME_TYPE.key(), "application/json");
+ session.getProvenanceReporter().modifyAttributes(flowFile,
BoxFileUtils.BOX_URL + fileId);
+ session.transfer(flowFile, REL_SUCCESS);
+
+ } catch (final BoxAPIResponseException e) {
+ flowFile = session.putAttribute(flowFile, ERROR_CODE,
valueOf(e.getResponseCode()));
+ flowFile = session.putAttribute(flowFile, ERROR_MESSAGE,
e.getMessage());
+ if (e.getResponseCode() == 404) {
+ final String errorBody = e.getResponse();
+ if (errorBody != null && errorBody.contains("Specified
Metadata Template not found")) {
+ getLogger().warn("Box metadata template was not found for
extraction request.");
+ session.transfer(flowFile, REL_TEMPLATE_NOT_FOUND);
+ } else {
+ getLogger().warn("Box file with ID {} was not found.",
fileId);
+ session.transfer(flowFile, REL_FILE_NOT_FOUND);
+ }
+ } else {
+ getLogger().error("Couldn't extract metadata from file with id
[{}]", fileId, e);
+ session.transfer(flowFile, REL_FAILURE);
+ }
+ } catch (final Exception e) {
+ getLogger().error("Error processing metadata extraction for Box
file [{}]", fileId, e);
+ flowFile = session.putAttribute(flowFile, ERROR_MESSAGE,
e.getMessage());
+ session.transfer(flowFile, REL_FAILURE);
+ }
+ }
+
+ BoxAIExtractStructuredResponse
getBoxAIExtractStructuredResponseWithTemplate(final String templateKey,
+
final String fileId) {
+ final BoxAIExtractMetadataTemplate template = new
BoxAIExtractMetadataTemplate(templateKey, SCOPE);
+ final BoxAIItem fileItem = new BoxAIItem(fileId, BoxAIItem.Type.FILE);
+ return BoxAI.extractMetadataStructured(boxAPIConnection,
Collections.singletonList(fileItem), template);
+ }
+
+ BoxAIExtractStructuredResponse
getBoxAIExtractStructuredResponseWithFields(final RecordReader recordReader,
+
final String fileId) throws IOException, MalformedRecordException {
+ final List<BoxAIExtractField> fields =
parseFieldsFromRecords(recordReader);
+ final BoxAIItem fileItem = new BoxAIItem(fileId, BoxAIItem.Type.FILE);
+ return BoxAI.extractMetadataStructured(boxAPIConnection,
Collections.singletonList(fileItem), fields);
+ }
+
+ private List<BoxAIExtractField> parseFieldsFromRecords(final RecordReader
recordReader) throws IOException, MalformedRecordException {
+ final List<BoxAIExtractField> fields = new ArrayList<>();
+ Record record;
+ while ((record = recordReader.nextRecord()) != null) {
+ final String key = record.getAsString("key");
+ if (key == null || key.trim().isEmpty()) {
+ throw new MalformedRecordException("Field record missing a key
field: " + record);
+ }
+
+ final String type = record.getAsString("type");
+ final String description = record.getAsString("description");
+ final String displayName = record.getAsString("displayName");
+ final String prompt = record.getAsString("prompt");
+
+ List<BoxAIExtractFieldOption> options = null;
+ final Object optionsObj = record.getValue("options");
+ if (optionsObj instanceof Iterable<?> iterable) {
+ options = new ArrayList<>();
+ for (Object option : iterable) {
+ if (option instanceof Record optionRecord) {
+ final String optionKey =
optionRecord.getAsString("key");
+ if (optionKey != null && !optionKey.trim().isEmpty()) {
+ options.add(new
BoxAIExtractFieldOption(optionKey));
+ } else {
+ getLogger().warn("Option record missing a valid
'key': {}", optionRecord);
+ }
+ } else {
+ getLogger().warn("Option is not a record: {}", option);
+ }
+ }
+ }
+ fields.add(new BoxAIExtractField(type, description, displayName,
key, options, prompt));
+ }
+ if (fields.isEmpty()) {
+ throw new MalformedRecordException("No valid field records found
in the input");
+ }
+ return fields;
+ }
+
+ public enum ExtractionMethod implements DescribedValue {
+ TEMPLATE("Template", "Uses a Box metadata template for extraction."),
+ FIELDS("Fields", "Uses a JSON schema of fields to extract from the
FlowFile content. " +
+ "The schema should include 'key' (required); 'type',
'description', 'displayName', 'prompt', and 'options' fields are optional. " +
+ "This follows the BOX API schema for fields.");
+
+ private final String displayName;
+ private final String description;
+
+ ExtractionMethod(String displayName, String description) {
+ this.displayName = displayName;
+ this.description = description;
+ }
+
+ @Override
+ public String getValue() {
+ return this.name();
+ }
+
+ @Override
+ public String getDisplayName() {
+ return this.displayName;
+ }
+
+ @Override
+ public String getDescription() {
+ return this.description;
+ }
+ }
+}
diff --git
a/nifi-extension-bundles/nifi-box-bundle/nifi-box-processors/src/main/java/org/apache/nifi/processors/box/ListBoxFileMetadataInstances.java
b/nifi-extension-bundles/nifi-box-bundle/nifi-box-processors/src/main/java/org/apache/nifi/processors/box/ListBoxFileMetadataInstances.java
new file mode 100644
index 0000000000..3c05d841a1
--- /dev/null
+++
b/nifi-extension-bundles/nifi-box-bundle/nifi-box-processors/src/main/java/org/apache/nifi/processors/box/ListBoxFileMetadataInstances.java
@@ -0,0 +1,267 @@
+/*
+ * 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.box;
+
+import com.box.sdk.BoxAPIConnection;
+import com.box.sdk.BoxAPIResponseException;
+import com.box.sdk.BoxFile;
+import com.box.sdk.Metadata;
+import com.eclipsesource.json.JsonValue;
+import org.apache.nifi.annotation.behavior.InputRequirement;
+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 org.apache.nifi.annotation.lifecycle.OnScheduled;
+import org.apache.nifi.box.controllerservices.BoxClientService;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.expression.ExpressionLanguageScope;
+import org.apache.nifi.flowfile.FlowFile;
+import org.apache.nifi.flowfile.attributes.CoreAttributes;
+import org.apache.nifi.processor.AbstractProcessor;
+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.processor.util.StandardValidators;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import static java.lang.String.valueOf;
+import static org.apache.nifi.processors.box.BoxFileAttributes.ERROR_CODE;
+import static org.apache.nifi.processors.box.BoxFileAttributes.ERROR_CODE_DESC;
+import static org.apache.nifi.processors.box.BoxFileAttributes.ERROR_MESSAGE;
+import static
org.apache.nifi.processors.box.BoxFileAttributes.ERROR_MESSAGE_DESC;
+
+@InputRequirement(InputRequirement.Requirement.INPUT_REQUIRED)
+@Tags({"box", "storage", "metadata", "instances", "templates"})
+@CapabilityDescription("Retrieves all metadata instances associated with a Box
file.")
+@SeeAlso({ListBoxFile.class, FetchBoxFile.class, FetchBoxFileInfo.class})
+@WritesAttributes({
+ @WritesAttribute(attribute = "box.id", description = "The ID of the
file from which metadata was fetched"),
+ @WritesAttribute(attribute = "record.count", description = "The number
of records in the FlowFile"),
+ @WritesAttribute(attribute = "mime.type", description = "The MIME Type
specified by the Record Writer"),
+ @WritesAttribute(attribute = "box.metadata.instances.names",
description = "Comma-separated list of instances names"),
+ @WritesAttribute(attribute = "box.metadata.instances.count",
description = "Number of metadata instances found"),
+ @WritesAttribute(attribute = ERROR_CODE, description =
ERROR_CODE_DESC),
+ @WritesAttribute(attribute = ERROR_MESSAGE, description =
ERROR_MESSAGE_DESC)
+})
+public class ListBoxFileMetadataInstances extends AbstractProcessor {
+
+ public static final PropertyDescriptor FILE_ID = new
PropertyDescriptor.Builder()
+ .name("File ID")
+ .description("The ID of the file for which to fetch metadata.")
+ .required(true)
+ .defaultValue("${box.id}")
+
.expressionLanguageSupported(ExpressionLanguageScope.FLOWFILE_ATTRIBUTES)
+ .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+ .build();
+
+ public static final Relationship REL_SUCCESS = new Relationship.Builder()
+ .name("success")
+ .description("A FlowFile containing the metadata instances records
will be routed to this relationship upon successful processing.")
+ .build();
+
+ public static final Relationship REL_FAILURE = new Relationship.Builder()
+ .name("failure")
+ .description("A FlowFile will be routed here if there is an error
fetching metadata instances from the file.")
+ .build();
+
+ public static final Relationship REL_NOT_FOUND = new Relationship.Builder()
+ .name("not found")
+ .description("FlowFiles for which the specified Box file was not
found will be routed to this relationship.")
+ .build();
+
+ public static final Set<Relationship> RELATIONSHIPS = Set.of(
+ REL_SUCCESS,
+ REL_FAILURE,
+ REL_NOT_FOUND
+ );
+
+ private static final List<PropertyDescriptor> PROPERTY_DESCRIPTORS =
List.of(
+ BoxClientService.BOX_CLIENT_SERVICE,
+ FILE_ID
+ );
+
+ private volatile BoxAPIConnection boxAPIConnection;
+
+ @Override
+ protected List<PropertyDescriptor> getSupportedPropertyDescriptors() {
+ return PROPERTY_DESCRIPTORS;
+ }
+
+ @Override
+ public Set<Relationship> getRelationships() {
+ return RELATIONSHIPS;
+ }
+
+ @OnScheduled
+ public void onScheduled(final ProcessContext context) {
+ final BoxClientService boxClientService =
context.getProperty(BoxClientService.BOX_CLIENT_SERVICE)
+ .asControllerService(BoxClientService.class);
+ boxAPIConnection = boxClientService.getBoxApiConnection();
+ }
+
+ @Override
+ public void onTrigger(final ProcessContext context, final ProcessSession
session) throws ProcessException {
+ FlowFile flowFile = session.get();
+ if (flowFile == null) {
+ return;
+ }
+
+ final String fileId =
context.getProperty(FILE_ID).evaluateAttributeExpressions(flowFile).getValue();
+
+ try {
+ final BoxFile boxFile = getBoxFile(fileId);
+
+ final List<Map<String, Object>> instanceList = new ArrayList<>();
+ final Iterable<Metadata> metadataList = boxFile.getAllMetadata();
+ final Iterator<Metadata> iterator = metadataList.iterator();
+ final Set<String> templateNames = new LinkedHashSet<>();
+
+ if (!iterator.hasNext()) {
+ flowFile = session.putAttribute(flowFile, "box.id", fileId);
+ flowFile = session.putAttribute(flowFile,
"box.metadata.instances.count", "0");
+ session.transfer(flowFile, REL_SUCCESS);
+ return;
+ }
+
+ while (iterator.hasNext()) {
+ final Metadata metadata = iterator.next();
+ final Map<String, Object> instanceFields = new HashMap<>();
+
+ templateNames.add(metadata.getTemplateName());
+
+ // Add standard metadata fields
+ instanceFields.put("$id", metadata.getID());
+ instanceFields.put("$type", metadata.getTypeName());
+ instanceFields.put("$parent", "file_" + fileId); // match the
Box API format
+ instanceFields.put("$template", metadata.getTemplateName());
+ instanceFields.put("$scope", metadata.getScope());
+
+ for (final String fieldName : metadata.getPropertyPaths()) {
+ final JsonValue jsonValue = metadata.getValue(fieldName);
+ if (jsonValue != null) {
+ final String cleanFieldName =
fieldName.startsWith("/") ? fieldName.substring(1) : fieldName;
+ final Object fieldValue = parseJsonValue(jsonValue);
+ instanceFields.put(cleanFieldName, fieldValue);
+ }
+ }
+ instanceList.add(instanceFields);
+ }
+
+ try {
+ try (final OutputStream out = session.write(flowFile);
+ final BoxMetadataJsonArrayWriter writer =
BoxMetadataJsonArrayWriter.create(out)) {
+
+ // Write each metadata template as a separate JSON object
in the array
+ for (Map<String, Object> templateFields : instanceList) {
+ writer.write(templateFields);
+ }
+ }
+
+ final Map<String, String> recordAttributes = new HashMap<>();
+ recordAttributes.put("record.count",
String.valueOf(instanceList.size()));
+ recordAttributes.put(CoreAttributes.MIME_TYPE.key(),
"application/json");
+ recordAttributes.put("box.id", fileId);
+ recordAttributes.put("box.metadata.instances.names",
String.join(",", templateNames));
+ recordAttributes.put("box.metadata.instances.count",
String.valueOf(instanceList.size()));
+ flowFile = session.putAllAttributes(flowFile,
recordAttributes);
+
+ session.getProvenanceReporter().receive(flowFile,
BoxFileUtils.BOX_URL + fileId + "/metadata");
+ session.transfer(flowFile, REL_SUCCESS);
+ } catch (final IOException e) {
+ getLogger().error("Failed writing metadata instances from file
[{}]", fileId, e);
+ flowFile = session.putAttribute(flowFile, ERROR_MESSAGE,
e.getMessage());
+ session.transfer(flowFile, REL_FAILURE);
+ }
+
+ } catch (final BoxAPIResponseException e) {
+ flowFile = session.putAttribute(flowFile, ERROR_CODE,
valueOf(e.getResponseCode()));
+ flowFile = session.putAttribute(flowFile, ERROR_MESSAGE,
e.getMessage());
+ if (e.getResponseCode() == 404) {
+ getLogger().warn("Box file with ID {} was not found.", fileId);
+ session.transfer(flowFile, REL_NOT_FOUND);
+ } else {
+ getLogger().error("Couldn't fetch metadata instances from file
with id [{}]", fileId, e);
+ session.transfer(flowFile, REL_FAILURE);
+ }
+ } catch (final Exception e) {
+ getLogger().error("Failed to process metadata instances for file
[{}]", fileId, e);
+ flowFile = session.putAttribute(flowFile, ERROR_MESSAGE,
e.getMessage());
+ session.transfer(flowFile, REL_FAILURE);
+ }
+ }
+
+ /**
+ * Returns a BoxFile object for the given file ID.
+ *
+ * @param fileId The ID of the file.
+ * @return A BoxFile object for the given file ID.
+ */
+ BoxFile getBoxFile(final String fileId) {
+ return new BoxFile(boxAPIConnection, fileId);
+ }
+
+ /**
+ * Parses a JsonValue and returns the appropriate Java object.
+ * Box does not allow exponential notation in metadata values, so we need
to handle
+ * special number formats. For numbers containing decimal points or
exponents, we try to
+ * convert them to BigDecimal first for precise representation. If that
fails, we
+ * fall back to double, which might lose precision but allows the
processing to continue.
+ *
+ * @param jsonValue The JsonValue to parse.
+ * @return The parsed Java object.
+ */
+ protected static Object parseJsonValue(final JsonValue jsonValue) {
+ if (jsonValue == null) {
+ return null;
+ }
+ if (jsonValue.isString()) {
+ return jsonValue.asString();
+ } else if (jsonValue.isNumber()) {
+ final String numberString = jsonValue.toString();
+ if (numberString.contains(".") ||
numberString.toLowerCase().contains("e")) {
+ try {
+ return (new BigDecimal(numberString)).toPlainString();
+ } catch (final NumberFormatException e) {
+ return jsonValue.asDouble();
+ }
+ } else {
+ try {
+ return jsonValue.asLong();
+ } catch (final NumberFormatException e) {
+ return (new BigDecimal(numberString)).toPlainString();
+ }
+ }
+ } else if (jsonValue.isBoolean()) {
+ return jsonValue.asBoolean();
+ }
+ // Fallback: return the string representation.
+ return jsonValue.toString();
+ }
+}
diff --git
a/nifi-extension-bundles/nifi-box-bundle/nifi-box-processors/src/main/java/org/apache/nifi/processors/box/UpdateBoxFileMetadataInstance.java
b/nifi-extension-bundles/nifi-box-bundle/nifi-box-processors/src/main/java/org/apache/nifi/processors/box/UpdateBoxFileMetadataInstance.java
new file mode 100644
index 0000000000..0f4fda7449
--- /dev/null
+++
b/nifi-extension-bundles/nifi-box-bundle/nifi-box-processors/src/main/java/org/apache/nifi/processors/box/UpdateBoxFileMetadataInstance.java
@@ -0,0 +1,328 @@
+/*
+ * 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.box;
+
+import com.box.sdk.BoxAPIConnection;
+import com.box.sdk.BoxAPIResponseException;
+import com.box.sdk.BoxFile;
+import com.box.sdk.Metadata;
+import org.apache.nifi.annotation.behavior.InputRequirement;
+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 org.apache.nifi.annotation.lifecycle.OnScheduled;
+import org.apache.nifi.box.controllerservices.BoxClientService;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.expression.ExpressionLanguageScope;
+import org.apache.nifi.flowfile.FlowFile;
+import org.apache.nifi.processor.AbstractProcessor;
+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.processor.util.StandardValidators;
+import org.apache.nifi.serialization.RecordReader;
+import org.apache.nifi.serialization.RecordReaderFactory;
+import org.apache.nifi.serialization.record.Record;
+
+import java.io.InputStream;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import static java.lang.String.valueOf;
+import static org.apache.nifi.processors.box.BoxFileAttributes.ERROR_CODE;
+import static org.apache.nifi.processors.box.BoxFileAttributes.ERROR_CODE_DESC;
+import static org.apache.nifi.processors.box.BoxFileAttributes.ERROR_MESSAGE;
+import static
org.apache.nifi.processors.box.BoxFileAttributes.ERROR_MESSAGE_DESC;
+
+@InputRequirement(InputRequirement.Requirement.INPUT_REQUIRED)
+@Tags({"box", "storage", "metadata", "templates", "update"})
+@CapabilityDescription("""
+ Updates metadata template values for a Box file using the record in
the given flowFile.\s
+ This record represents the desired end state of the template after
the update.\s
+ The processor will calculate the necessary changes
(add/replace/remove) to transform
+ the current metadata to the desired state. The input record should be
a flat key-value object.
+ """)
+@SeeAlso({ListBoxFileMetadataTemplates.class, ListBoxFile.class,
FetchBoxFile.class})
+@WritesAttributes({
+ @WritesAttribute(attribute = "box.id", description = "The ID of the
file whose metadata was updated"),
+ @WritesAttribute(attribute = "box.template.name", description = "The
template name used for metadata update"),
+ @WritesAttribute(attribute = "box.template.scope", description = "The
template scope used for metadata update"),
+ @WritesAttribute(attribute = ERROR_CODE, description =
ERROR_CODE_DESC),
+ @WritesAttribute(attribute = ERROR_MESSAGE, description =
ERROR_MESSAGE_DESC)
+})
+public class UpdateBoxFileMetadataInstance extends AbstractProcessor {
+
+ public static final PropertyDescriptor FILE_ID = new
PropertyDescriptor.Builder()
+ .name("File ID")
+ .description("The ID of the file for which to update metadata.")
+ .required(true)
+ .defaultValue("${box.id}")
+
.expressionLanguageSupported(ExpressionLanguageScope.FLOWFILE_ATTRIBUTES)
+ .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+ .build();
+
+ public static final PropertyDescriptor TEMPLATE_KEY = new
PropertyDescriptor.Builder()
+ .name("Template Key")
+ .description("The key of the metadata template to update.")
+ .required(true)
+
.expressionLanguageSupported(ExpressionLanguageScope.FLOWFILE_ATTRIBUTES)
+ .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+ .build();
+
+
+ public static final PropertyDescriptor RECORD_READER = new
PropertyDescriptor.Builder()
+ .name("Record Reader")
+ .description("The Record Reader to use for parsing the incoming
data")
+ .required(true)
+ .identifiesControllerService(RecordReaderFactory.class)
+ .build();
+
+ public static final Relationship REL_SUCCESS = new Relationship.Builder()
+ .name("success")
+ .description("A FlowFile is routed to this relationship after
metadata has been successfully updated.")
+ .build();
+
+ public static final Relationship REL_FAILURE = new Relationship.Builder()
+ .name("failure")
+ .description("A FlowFile is routed to this relationship if an
error occurs during metadata update.")
+ .build();
+
+ public static final Relationship REL_FILE_NOT_FOUND = new
Relationship.Builder()
+ .name("file not found")
+ .description("FlowFiles for which the specified Box file was not
found will be routed to this relationship.")
+ .build();
+
+ public static final Relationship REL_TEMPLATE_NOT_FOUND = new
Relationship.Builder()
+ .name("template not found")
+ .description("FlowFiles for which the specified metadata template
was not found will be routed to this relationship.")
+ .build();
+
+ private static final List<PropertyDescriptor> PROPERTY_DESCRIPTORS =
List.of(
+ BoxClientService.BOX_CLIENT_SERVICE,
+ FILE_ID,
+ TEMPLATE_KEY,
+ RECORD_READER
+ );
+
+ private static final Set<Relationship> RELATIONSHIPS = Set.of(
+ REL_SUCCESS,
+ REL_FAILURE,
+ REL_FILE_NOT_FOUND,
+ REL_TEMPLATE_NOT_FOUND
+ );
+
+ private volatile BoxAPIConnection boxAPIConnection;
+
+ @Override
+ protected List<PropertyDescriptor> getSupportedPropertyDescriptors() {
+ return PROPERTY_DESCRIPTORS;
+ }
+
+ @Override
+ public Set<Relationship> getRelationships() {
+ return RELATIONSHIPS;
+ }
+
+ @OnScheduled
+ public void onScheduled(final ProcessContext context) {
+ final BoxClientService boxClientService =
context.getProperty(BoxClientService.BOX_CLIENT_SERVICE)
+ .asControllerService(BoxClientService.class);
+ boxAPIConnection = boxClientService.getBoxApiConnection();
+ }
+
+ @Override
+ public void onTrigger(final ProcessContext context, final ProcessSession
session) throws ProcessException {
+ FlowFile flowFile = session.get();
+ if (flowFile == null) {
+ return;
+ }
+
+ final String fileId =
context.getProperty(FILE_ID).evaluateAttributeExpressions(flowFile).getValue();
+ final String templateKey =
context.getProperty(TEMPLATE_KEY).evaluateAttributeExpressions(flowFile).getValue();
+ final RecordReaderFactory recordReaderFactory =
context.getProperty(RECORD_READER).asControllerService(RecordReaderFactory.class);
+
+ try {
+ final BoxFile boxFile = getBoxFile(fileId);
+ final Map<String, Object> desiredState = readDesiredState(session,
flowFile, recordReaderFactory);
+
+ if (desiredState.isEmpty()) {
+ flowFile = session.putAttribute(flowFile, ERROR_MESSAGE, "No
valid metadata key-value pairs found in the input");
+ session.transfer(flowFile, REL_FAILURE);
+ return;
+ }
+
+ final Metadata metadata = getMetadata(boxFile, templateKey);
+ updateMetadata(metadata, desiredState);
+
+ if (!metadata.getOperations().isEmpty()) {
+ getLogger().info("Updating {} metadata fields for file {}",
metadata.getOperations().size(), fileId);
+ updateBoxFileMetadata(boxFile, metadata);
+ }
+
+ final Map<String, String> attributes = Map.of(
+ "box.id", fileId,
+ "box.template.key", templateKey);
+ flowFile = session.putAllAttributes(flowFile, attributes);
+
+ session.getProvenanceReporter().modifyAttributes(flowFile,
"%s%s/metadata/enterprise/%s".formatted(BoxFileUtils.BOX_URL, fileId,
templateKey));
+ session.transfer(flowFile, REL_SUCCESS);
+
+ } catch (final BoxAPIResponseException e) {
+ flowFile = session.putAttribute(flowFile, ERROR_CODE,
valueOf(e.getResponseCode()));
+ flowFile = session.putAttribute(flowFile, ERROR_MESSAGE,
e.getMessage());
+ final String errorBody = e.getResponse();
+ if (errorBody != null &&
errorBody.toLowerCase().contains("specified metadata template not found")) {
+ getLogger().warn("Box metadata template with key {} was not
found.", templateKey);
+ session.transfer(flowFile, REL_TEMPLATE_NOT_FOUND);
+ } else {
+ getLogger().warn("Box file with ID {} was not found.", fileId);
+ session.transfer(flowFile, REL_FILE_NOT_FOUND);
+ }
+ } catch (Exception e) {
+ getLogger().error("Error processing metadata update for Box file
[{}]", fileId, e);
+ flowFile = session.putAttribute(flowFile, ERROR_MESSAGE,
e.getMessage());
+ session.transfer(flowFile, REL_FAILURE);
+ }
+ }
+
+ private Map<String, Object> readDesiredState(final ProcessSession session,
+ final FlowFile flowFile,
+ final RecordReaderFactory
recordReaderFactory) throws Exception {
+ final Map<String, Object> desiredState = new HashMap<>();
+
+ try (final InputStream inputStream = session.read(flowFile);
+ final RecordReader recordReader =
recordReaderFactory.createRecordReader(flowFile, inputStream, getLogger())) {
+
+ final Record record = recordReader.nextRecord();
+ if (record != null) {
+ for (String fieldName : record.getSchema().getFieldNames()) {
+ desiredState.put(fieldName, record.getValue(fieldName));
+ }
+ }
+ }
+
+ return desiredState;
+ }
+
+ private void updateMetadata(final Metadata metadata,
+ final Map<String, Object> desiredState) {
+ final List<String> currentKeys = metadata.getPropertyPaths();
+
+ // Remove fields not in desired state
+ for (final String propertyPath : currentKeys) {
+ final String fieldName = propertyPath.substring(1); // Remove
leading '/'
+
+ if (!desiredState.containsKey(fieldName)) {
+ metadata.remove(propertyPath);
+ getLogger().debug("Removing metadata field: {}", fieldName);
+ }
+ }
+
+ // Add or update fields
+ for (final Map.Entry<String, Object> entry : desiredState.entrySet()) {
+ final String fieldName = entry.getKey();
+ final Object value = entry.getValue();
+ final String propertyPath = "/" + fieldName;
+
+ updateField(metadata, propertyPath, value,
currentKeys.contains(propertyPath));
+ }
+ }
+
+ private void updateField(final Metadata metadata,
+ final String propertyPath,
+ final Object value,
+ final boolean exists) {
+ if (value == null) {
+ throw new IllegalArgumentException("Null value found for property
path: " + propertyPath);
+ }
+
+ if (exists) {
+ final Object currentValue = metadata.getValue(propertyPath);
+
+ // Only update if values are different
+ if (Objects.equals(currentValue, value)) {
+ return;
+ }
+
+ // Update
+ switch (value) {
+ case Number n -> metadata.replace(propertyPath,
n.doubleValue());
+ case List<?> l -> metadata.replace(propertyPath,
convertListToStringList(l, propertyPath));
+ default -> metadata.replace(propertyPath, value.toString());
+ }
+ } else {
+ // Add new field
+ switch (value) {
+ case Number n -> metadata.add(propertyPath, n.doubleValue());
+ case List<?> l -> metadata.add(propertyPath,
convertListToStringList(l, propertyPath));
+ default -> metadata.add(propertyPath, value.toString());
+ }
+ }
+ }
+
+ private List<String> convertListToStringList(final List<?> list,
+ final String fieldName) {
+ return list.stream()
+ .map(obj -> {
+ if (obj == null) {
+ throw new IllegalArgumentException("Null value found
in list for field: " + fieldName);
+ }
+ return obj.toString();
+ })
+ .collect(Collectors.toList());
+ }
+
+ /**
+ * Retrieves the metadata for a Box file.
+ * Visible for testing purposes.
+ *
+ * @param boxFile The Box file to retrieve metadata from.
+ * @param templateKey The key of the metadata template.
+ * @return The metadata for the Box file.
+ */
+ Metadata getMetadata(final BoxFile boxFile,
+ final String templateKey) {
+ return boxFile.getMetadata(templateKey);
+ }
+
+ /**
+ * Returns a BoxFile object for the given file ID.
+ *
+ * @param fileId The ID of the file.
+ * @return A BoxFile object for the given file ID.
+ */
+ BoxFile getBoxFile(final String fileId) {
+ return new BoxFile(boxAPIConnection, fileId);
+ }
+
+ /**
+ * Updates the metadata for a Box file.
+ *
+ * @param boxFile The Box file to update.
+ * @param metadata The metadata to update.
+ */
+ void updateBoxFileMetadata(final BoxFile boxFile, final Metadata metadata)
{
+ boxFile.updateMetadata(metadata);
+ }
+}
diff --git
a/nifi-extension-bundles/nifi-box-bundle/nifi-box-processors/src/main/resources/META-INF/services/org.apache.nifi.processor.Processor
b/nifi-extension-bundles/nifi-box-bundle/nifi-box-processors/src/main/resources/META-INF/services/org.apache.nifi.processor.Processor
index d2c21746e2..74ed0b7e9c 100644
---
a/nifi-extension-bundles/nifi-box-bundle/nifi-box-processors/src/main/resources/META-INF/services/org.apache.nifi.processor.Processor
+++
b/nifi-extension-bundles/nifi-box-bundle/nifi-box-processors/src/main/resources/META-INF/services/org.apache.nifi.processor.Processor
@@ -15,6 +15,9 @@
org.apache.nifi.processors.box.ConsumeBoxEnterpriseEvents
org.apache.nifi.processors.box.ConsumeBoxEvents
+org.apache.nifi.processors.box.CreateBoxMetadataTemplate
+org.apache.nifi.processors.box.CreateBoxFileMetadataInstance
+org.apache.nifi.processors.box.ExtractStructuredBoxFileMetadata
org.apache.nifi.processors.box.FetchBoxFile
org.apache.nifi.processors.box.FetchBoxFileInfo
org.apache.nifi.processors.box.FetchBoxFileRepresentation
@@ -22,5 +25,7 @@ org.apache.nifi.processors.box.GetBoxFileCollaborators
org.apache.nifi.processors.box.GetBoxGroupMembers
org.apache.nifi.processors.box.ListBoxFile
org.apache.nifi.processors.box.ListBoxFileInfo
+org.apache.nifi.processors.box.ListBoxFileMetadataInstances
org.apache.nifi.processors.box.ListBoxFileMetadataTemplates
org.apache.nifi.processors.box.PutBoxFile
+org.apache.nifi.processors.box.UpdateBoxFileMetadataInstance
diff --git
a/nifi-extension-bundles/nifi-box-bundle/nifi-box-processors/src/test/java/org/apache/nifi/processors/box/CreateBoxFileMetadataInstanceTest.java
b/nifi-extension-bundles/nifi-box-bundle/nifi-box-processors/src/test/java/org/apache/nifi/processors/box/CreateBoxFileMetadataInstanceTest.java
new file mode 100644
index 0000000000..eb7d71a86c
--- /dev/null
+++
b/nifi-extension-bundles/nifi-box-bundle/nifi-box-processors/src/test/java/org/apache/nifi/processors/box/CreateBoxFileMetadataInstanceTest.java
@@ -0,0 +1,160 @@
+/*
+ * 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.box;
+
+import com.box.sdk.BoxAPIResponseException;
+import com.box.sdk.BoxFile;
+import com.box.sdk.Metadata;
+import org.apache.nifi.json.JsonTreeReader;
+import org.apache.nifi.reporting.InitializationException;
+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 org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.verify;
+
+@ExtendWith(MockitoExtension.class)
+public class CreateBoxFileMetadataInstanceTest extends AbstractBoxFileTest {
+
+ private static final String TEMPLATE_NAME = "fileProperties";
+
+ @Mock
+ private BoxFile mockBoxFile;
+
+ @Override
+ @BeforeEach
+ void setUp() throws Exception {
+ final CreateBoxFileMetadataInstance testSubject = new
CreateBoxFileMetadataInstance() {
+ @Override
+ BoxFile getBoxFile(String fileId) {
+ return mockBoxFile;
+ }
+ };
+
+ testRunner = TestRunners.newTestRunner(testSubject);
+ super.setUp();
+
+ configureJsonRecordReader(testRunner);
+
+ testRunner.setProperty(CreateBoxFileMetadataInstance.FILE_ID,
TEST_FILE_ID);
+ testRunner.setProperty(CreateBoxFileMetadataInstance.TEMPLATE_KEY,
TEMPLATE_NAME);
+ testRunner.setProperty(CreateBoxFileMetadataInstance.RECORD_READER,
"json-reader");
+ }
+
+ private void configureJsonRecordReader(TestRunner runner) throws
InitializationException {
+ final JsonTreeReader readerService = new JsonTreeReader();
+
+ runner.addControllerService("json-reader", readerService);
+ runner.setProperty(readerService, "Date Format", "yyyy-MM-dd");
+ runner.setProperty(readerService, "Timestamp Format", "yyyy-MM-dd
HH:mm:ss");
+
+ runner.enableControllerService(readerService);
+ }
+
+ @Test
+ void testSuccessfulMetadataCreation() {
+ final String inputJson = """
+ {
+ "audience": "internal",
+ "documentType": "Q1 plans",
+ "competitiveDocument": "no",
+ "status": "active",
+ "author": "Jones"
+ }""";
+
+ testRunner.enqueue(inputJson);
+ testRunner.run();
+
+ final ArgumentCaptor<Metadata> metadataCaptor =
ArgumentCaptor.forClass(Metadata.class);
+ verify(mockBoxFile).createMetadata(any(), metadataCaptor.capture());
+
+ final Metadata capturedMetadata = metadataCaptor.getValue();
+ assertEquals("internal",
capturedMetadata.getValue("/audience").asString());
+ assertEquals("Q1 plans",
capturedMetadata.getValue("/documentType").asString());
+ assertEquals("no",
capturedMetadata.getValue("/competitiveDocument").asString());
+ assertEquals("active",
capturedMetadata.getValue("/status").asString());
+ assertEquals("Jones", capturedMetadata.getValue("/author").asString());
+
+
testRunner.assertAllFlowFilesTransferred(CreateBoxFileMetadataInstance.REL_SUCCESS,
1);
+ final MockFlowFile flowFile =
testRunner.getFlowFilesForRelationship(CreateBoxFileMetadataInstance.REL_SUCCESS).getFirst();
+
+ flowFile.assertAttributeEquals("box.id", TEST_FILE_ID);
+ flowFile.assertAttributeEquals("box.template.key", TEMPLATE_NAME);
+ }
+
+ @Test
+ void testEmptyInput() {
+ final String inputJson = "{}";
+
+ testRunner.enqueue(inputJson);
+ testRunner.run();
+
+
testRunner.assertAllFlowFilesTransferred(CreateBoxFileMetadataInstance.REL_FAILURE,
1);
+ final MockFlowFile flowFile =
testRunner.getFlowFilesForRelationship(CreateBoxFileMetadataInstance.REL_FAILURE).getFirst();
+ flowFile.assertAttributeExists("error.message");
+ }
+
+ @Test
+ void testFileNotFound() {
+ final BoxAPIResponseException mockException = new
BoxAPIResponseException("API Error", 404, "Box File Not Found", null);
+
doThrow(mockException).when(mockBoxFile).createMetadata(any(String.class),
any(Metadata.class));
+
+ final String inputJson = """
+ {
+ "audience": "internal",
+ "documentType": "Q1 plans"
+ }""";
+
+ testRunner.enqueue(inputJson);
+ testRunner.run();
+
+
testRunner.assertAllFlowFilesTransferred(CreateBoxFileMetadataInstance.REL_FILE_NOT_FOUND,
1);
+ final MockFlowFile flowFile =
testRunner.getFlowFilesForRelationship(CreateBoxFileMetadataInstance.REL_FILE_NOT_FOUND).getFirst();
+ flowFile.assertAttributeEquals(BoxFileAttributes.ERROR_CODE, "404");
+ flowFile.assertAttributeEquals(BoxFileAttributes.ERROR_MESSAGE, "API
Error [404]");
+ }
+
+ @Test
+ void testTemplateNotFound() {
+ final BoxAPIResponseException mockException = new
BoxAPIResponseException("API Error", 404, "Specified Metadata Template not
found", null);
+
doThrow(mockException).when(mockBoxFile).createMetadata(any(String.class),
any(Metadata.class));
+
+ final String inputJson = """
+ {
+ "audience": "internal",
+ "documentType": "Q1 plans"
+ }""";
+
+ testRunner.enqueue(inputJson);
+ testRunner.run();
+
+
testRunner.assertAllFlowFilesTransferred(CreateBoxFileMetadataInstance.REL_TEMPLATE_NOT_FOUND,
1);
+ final MockFlowFile flowFile =
testRunner.getFlowFilesForRelationship(CreateBoxFileMetadataInstance.REL_TEMPLATE_NOT_FOUND).getFirst();
+ flowFile.assertAttributeEquals(BoxFileAttributes.ERROR_CODE, "404");
+ flowFile.assertAttributeEquals(BoxFileAttributes.ERROR_MESSAGE, "API
Error [404]");
+ }
+
+}
diff --git
a/nifi-extension-bundles/nifi-box-bundle/nifi-box-processors/src/test/java/org/apache/nifi/processors/box/CreateBoxMetadataTemplateTest.java
b/nifi-extension-bundles/nifi-box-bundle/nifi-box-processors/src/test/java/org/apache/nifi/processors/box/CreateBoxMetadataTemplateTest.java
new file mode 100644
index 0000000000..3e33608400
--- /dev/null
+++
b/nifi-extension-bundles/nifi-box-bundle/nifi-box-processors/src/test/java/org/apache/nifi/processors/box/CreateBoxMetadataTemplateTest.java
@@ -0,0 +1,219 @@
+/*
+ * 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.box;
+
+import com.box.sdk.BoxAPIConnection;
+import com.box.sdk.MetadataTemplate;
+import org.apache.nifi.json.JsonTreeReader;
+import org.apache.nifi.processor.ProcessContext;
+import org.apache.nifi.reporting.InitializationException;
+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 org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+@ExtendWith(MockitoExtension.class)
+public class CreateBoxMetadataTemplateTest extends AbstractBoxFileTest {
+
+ private static final String TEMPLATE_NAME = "Test Template";
+ private static final String TEMPLATE_KEY = "test_template";
+ private static final String HIDDEN_VALUE = "false";
+
+ private List<MetadataTemplate.Field> capturedFields;
+ private String capturedTemplateKey;
+ private String capturedTemplateName;
+ private Boolean capturedHidden;
+
+ private class TestCreateBoxMetadataTemplate extends
CreateBoxMetadataTemplate {
+ @Override
+ protected BoxAPIConnection getBoxAPIConnection(ProcessContext context)
{
+ return mockBoxAPIConnection;
+ }
+
+ @Override
+ protected void createBoxMetadataTemplate(final BoxAPIConnection
boxAPIConnection,
+ final String templateKey,
+ final String templateName,
+ final boolean isHidden,
+ final
List<MetadataTemplate.Field> fields) {
+ capturedFields = fields;
+ capturedTemplateKey = templateKey;
+ capturedTemplateName = templateName;
+ capturedHidden = isHidden;
+ }
+ }
+
+ @Override
+ @BeforeEach
+ void setUp() throws Exception {
+ final TestCreateBoxMetadataTemplate processor = new
TestCreateBoxMetadataTemplate();
+ testRunner = TestRunners.newTestRunner(processor);
+ super.setUp();
+
+ configureJsonRecordReader(testRunner);
+ testRunner.setProperty(CreateBoxMetadataTemplate.TEMPLATE_NAME,
TEMPLATE_NAME);
+ testRunner.setProperty(CreateBoxMetadataTemplate.TEMPLATE_KEY,
TEMPLATE_KEY);
+ testRunner.setProperty(CreateBoxMetadataTemplate.HIDDEN, HIDDEN_VALUE);
+ testRunner.setProperty(CreateBoxMetadataTemplate.RECORD_READER,
"json-reader");
+ }
+
+ private void configureJsonRecordReader(TestRunner runner) throws
InitializationException {
+ final JsonTreeReader readerService = new JsonTreeReader();
+ runner.addControllerService("json-reader", readerService);
+ runner.enableControllerService(readerService);
+ }
+
+ @Test
+ void testSuccessfulTemplateCreation() {
+ final String inputJson = """
+ [
+ {"key": "field1", "type": "string", "displayName": "Field
One"},
+ {"key": "field2", "type": "float"}
+ ]
+ """;
+
+ testRunner.enqueue(inputJson);
+ testRunner.run();
+ assertEquals(2, capturedFields.size());
+
+ final MetadataTemplate.Field field1 = capturedFields.getFirst();
+ assertEquals("field1", field1.getKey());
+ assertEquals("string", field1.getType());
+ assertEquals("Field One", field1.getDisplayName());
+
+ final MetadataTemplate.Field field2 = capturedFields.get(1);
+ assertEquals("field2", field2.getKey());
+ assertEquals("float", field2.getType());
+
+
testRunner.assertAllFlowFilesTransferred(CreateBoxMetadataTemplate.REL_SUCCESS,
1);
+ final MockFlowFile flowFile =
testRunner.getFlowFilesForRelationship(CreateBoxMetadataTemplate.REL_SUCCESS).get(0);
+ flowFile.assertAttributeEquals("box.template.name", TEMPLATE_NAME);
+ flowFile.assertAttributeEquals("box.template.key", TEMPLATE_KEY);
+ flowFile.assertAttributeEquals("box.template.scope",
CreateBoxMetadataTemplate.SCOPE_ENTERPRISE);
+ flowFile.assertAttributeEquals("box.template.fields.count", "2");
+ }
+
+ @Test
+ void testEmptyInput() {
+ final String inputJson = "[]";
+
+ testRunner.enqueue(inputJson);
+ testRunner.run();
+
+
testRunner.assertAllFlowFilesTransferred(CreateBoxMetadataTemplate.REL_FAILURE,
1);
+ final MockFlowFile flowFile =
testRunner.getFlowFilesForRelationship(CreateBoxMetadataTemplate.REL_FAILURE).getFirst();
+ flowFile.assertAttributeEquals("error.message", "No valid metadata
field specifications found in the input");
+ }
+
+ @Test
+ void testInvalidRecords() {
+ // First record missing the key; second record has an invalid type.
+ final String inputJson = """
+ [
+ {"type": "string", "displayName": "No Key"},
+ {"key": "field2", "type": "invalid"}
+ ]
+ """;
+
+ testRunner.enqueue(inputJson);
+ testRunner.run();
+
+
testRunner.assertAllFlowFilesTransferred(CreateBoxMetadataTemplate.REL_FAILURE,
1);
+ final MockFlowFile flowFile =
testRunner.getFlowFilesForRelationship(CreateBoxMetadataTemplate.REL_FAILURE).getFirst();
+ final String errorMessage = flowFile.getAttribute("error.message");
+ assertTrue(errorMessage.contains("missing a key field"));
+ assertTrue(errorMessage.contains("has an invalid type"));
+ }
+
+ @Test
+ void testExpressionLanguage() {
+ testRunner.setProperty(CreateBoxMetadataTemplate.TEMPLATE_NAME,
"${template.name}");
+ testRunner.setProperty(CreateBoxMetadataTemplate.TEMPLATE_KEY,
"${template.key}");
+ testRunner.setProperty(CreateBoxMetadataTemplate.HIDDEN, "true");
+
+ final Map<String, String> attributes = new HashMap<>();
+ attributes.put("template.name", "Template Name");
+ attributes.put("template.key", "templateKey");
+
+ final String inputJson = """
+ [
+ {"key": "field1", "type": "date", "displayName": "Date
Field"}
+ ]
+ """;
+
+ testRunner.enqueue(inputJson, attributes);
+ testRunner.run();
+
+ assertEquals("templateKey", capturedTemplateKey);
+ assertEquals("Template Name", capturedTemplateName);
+ assertEquals(true, capturedHidden);
+ assertEquals(1, capturedFields.size());
+
+ final MetadataTemplate.Field field = capturedFields.getFirst();
+ assertEquals("field1", field.getKey());
+ assertEquals("date", field.getType());
+ assertEquals("Date Field", field.getDisplayName());
+
+
testRunner.assertAllFlowFilesTransferred(CreateBoxMetadataTemplate.REL_SUCCESS,
1);
+ final MockFlowFile flowFile =
testRunner.getFlowFilesForRelationship(CreateBoxMetadataTemplate.REL_SUCCESS).getFirst();
+ flowFile.assertAttributeEquals("box.template.name", "Template Name");
+ flowFile.assertAttributeEquals("box.template.key", "templateKey");
+ flowFile.assertAttributeEquals("box.template.scope",
CreateBoxMetadataTemplate.SCOPE_ENTERPRISE);
+ flowFile.assertAttributeEquals("box.template.fields.count", "1");
+ }
+
+ @Test
+ void testAllFieldTypes() {
+ final String inputJson = """
+ [
+ {"key": "strField", "type": "string", "displayName":
"String Field", "description": "A string field", "hidden": false},
+ {"key": "numField", "type": "float", "displayName":
"Number Field", "description": "A float field", "hidden": true},
+ {"key": "dateField", "type": "date", "displayName": "Date
Field", "description": "A date field"}
+ ]
+ """;
+
+ testRunner.enqueue(inputJson);
+ testRunner.run();
+
+ assertEquals(3, capturedFields.size());
+ assertEquals("string", capturedFields.get(0).getType());
+ assertEquals("float", capturedFields.get(1).getType());
+ assertEquals("date", capturedFields.get(2).getType());
+ assertEquals("String Field", capturedFields.get(0).getDisplayName());
+ assertEquals("Number Field", capturedFields.get(1).getDisplayName());
+ assertEquals("Date Field", capturedFields.get(2).getDisplayName());
+ assertEquals("A string field", capturedFields.get(0).getDescription());
+ assertEquals("A float field", capturedFields.get(1).getDescription());
+ assertEquals("A date field", capturedFields.get(2).getDescription());
+ assertEquals(false, capturedFields.get(0).getIsHidden());
+ assertEquals(true, capturedFields.get(1).getIsHidden());
+
+
testRunner.assertAllFlowFilesTransferred(CreateBoxMetadataTemplate.REL_SUCCESS,
1);
+ final MockFlowFile flowFile =
testRunner.getFlowFilesForRelationship(CreateBoxMetadataTemplate.REL_SUCCESS).getFirst();
+ flowFile.assertAttributeEquals("box.template.fields.count", "3");
+ }
+}
diff --git
a/nifi-extension-bundles/nifi-box-bundle/nifi-box-processors/src/test/java/org/apache/nifi/processors/box/ExtractStructuredBoxFileMetadataTest.java
b/nifi-extension-bundles/nifi-box-bundle/nifi-box-processors/src/test/java/org/apache/nifi/processors/box/ExtractStructuredBoxFileMetadataTest.java
new file mode 100644
index 0000000000..f1606fc8b6
--- /dev/null
+++
b/nifi-extension-bundles/nifi-box-bundle/nifi-box-processors/src/test/java/org/apache/nifi/processors/box/ExtractStructuredBoxFileMetadataTest.java
@@ -0,0 +1,303 @@
+/*
+ * 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.box;
+
+import com.box.sdk.BoxAIExtractStructuredResponse;
+import com.box.sdk.BoxAPIResponseException;
+import com.eclipsesource.json.JsonObject;
+import org.apache.nifi.controller.AbstractControllerService;
+import org.apache.nifi.flowfile.FlowFile;
+import org.apache.nifi.logging.ComponentLog;
+import
org.apache.nifi.processors.box.ExtractStructuredBoxFileMetadata.ExtractionMethod;
+import org.apache.nifi.provenance.ProvenanceEventType;
+import org.apache.nifi.serialization.RecordReader;
+import org.apache.nifi.serialization.RecordReaderFactory;
+import org.apache.nifi.util.MockFlowFile;
+import org.apache.nifi.util.TestRunners;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.io.InputStream;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.function.BiFunction;
+import java.util.function.Function;
+
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.mock;
+
+@ExtendWith(MockitoExtension.class)
+public class ExtractStructuredBoxFileMetadataTest extends AbstractBoxFileTest {
+
+ // Simple mock RecordReaderFactory for testing
+ private static class MockJsonRecordReaderFactory extends
AbstractControllerService implements RecordReaderFactory {
+ @Override
+ public RecordReader createRecordReader(Map<String, String> variables,
InputStream in, long l, ComponentLog componentLog) {
+ return mock(RecordReader.class);
+ }
+
+ @Override
+ public RecordReader createRecordReader(FlowFile flowFile, InputStream
in, ComponentLog componentLog) {
+ return mock(RecordReader.class);
+ }
+ }
+
+ private static final String TEMPLATE_KEY = "testTemplate";
+ private static final String FIELDS_JSON = """
+ [
+ {
+ "key": "name",
+ "description": "The name of the person.",
+ "displayName": "Name",
+ "prompt": "The name is the first and last name from the email
address.",
+ "type": "string",
+ "options": [
+ { "key": "First Name" },
+ { "key": "Last Name" }
+ ]
+ }
+ ]
+ """;
+ private static final String COMPLETION_REASON = "success";
+ private static final Date CREATED_AT = new Date();
+
+ @Mock
+ private BoxAIExtractStructuredResponse mockAIResponse;
+
+ // Suppliers to simulate responses from the Box API calls.
+ private BiFunction<String, String, BoxAIExtractStructuredResponse>
templateResponseSupplier;
+ private Function<InputStream, BoxAIExtractStructuredResponse>
fieldsInputStreamResponseSupplier;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ // Default suppliers simply return the mock response.
+ templateResponseSupplier = (templateKey, fileId) -> mockAIResponse;
+ fieldsInputStreamResponseSupplier = (inputStream) -> mockAIResponse;
+
+ // Override the processor methods to use our suppliers.
+ final ExtractStructuredBoxFileMetadata testSubject = new
ExtractStructuredBoxFileMetadata() {
+ @Override
+ BoxAIExtractStructuredResponse
getBoxAIExtractStructuredResponseWithTemplate(final String templateKey,
+
final String fileId) {
+ return templateResponseSupplier.apply(templateKey, fileId);
+ }
+
+ @Override
+ BoxAIExtractStructuredResponse
getBoxAIExtractStructuredResponseWithFields(final RecordReader recordReader,
+
final String fileId) {
+ // For testing, simply use the supplier.
+ return fieldsInputStreamResponseSupplier.apply(null);
+ }
+ };
+
+ testRunner = TestRunners.newTestRunner(testSubject);
+ super.setUp();
+
+ testRunner.setProperty(ExtractStructuredBoxFileMetadata.FILE_ID,
TEST_FILE_ID);
+
testRunner.setProperty(ExtractStructuredBoxFileMetadata.EXTRACTION_METHOD,
ExtractionMethod.TEMPLATE.getValue());
+ testRunner.setProperty(ExtractStructuredBoxFileMetadata.TEMPLATE_KEY,
TEMPLATE_KEY);
+
+ // Add and enable a mock RecordReader service for FIELDS extraction.
+ final MockJsonRecordReaderFactory mockReaderFactory = new
MockJsonRecordReaderFactory();
+ testRunner.addControllerService("mockReader", mockReaderFactory);
+ testRunner.enableControllerService(mockReaderFactory);
+ testRunner.setProperty(ExtractStructuredBoxFileMetadata.RECORD_READER,
"mockReader");
+
+
lenient().when(mockAIResponse.getCompletionReason()).thenReturn(COMPLETION_REASON);
+ lenient().when(mockAIResponse.getCreatedAt()).thenReturn(CREATED_AT);
+ // Prepare a sample JSON answer.
+ JsonObject jsonAnswer = new JsonObject();
+ jsonAnswer.add("title", "Sample Document");
+ jsonAnswer.add("author", "John Doe");
+ lenient().when(mockAIResponse.getAnswer()).thenReturn(jsonAnswer);
+ }
+
+ @Test
+ void testSuccessfulMetadataExtractionWithTemplate() {
+ testRunner.enqueue("test data");
+ testRunner.run();
+
+
testRunner.assertAllFlowFilesTransferred(ExtractStructuredBoxFileMetadata.REL_SUCCESS,
1);
+ final MockFlowFile flowFile =
testRunner.getFlowFilesForRelationship(ExtractStructuredBoxFileMetadata.REL_SUCCESS).get(0);
+
+ flowFile.assertAttributeEquals("box.id", TEST_FILE_ID);
+ flowFile.assertAttributeEquals("box.ai.template.key", TEMPLATE_KEY);
+ flowFile.assertAttributeEquals("box.ai.extraction.method",
ExtractionMethod.TEMPLATE.name());
+ flowFile.assertAttributeEquals("box.ai.completion.reason",
COMPLETION_REASON);
+
+ assertProvenanceEvent(ProvenanceEventType.ATTRIBUTES_MODIFIED);
+ }
+
+ @Test
+ void testSuccessfulMetadataExtractionWithFields() {
+
testRunner.setProperty(ExtractStructuredBoxFileMetadata.EXTRACTION_METHOD,
ExtractionMethod.FIELDS.getValue());
+ // Remove the template key property when using FIELDS.
+
testRunner.removeProperty(ExtractStructuredBoxFileMetadata.TEMPLATE_KEY);
+
+ testRunner.enqueue(FIELDS_JSON);
+ testRunner.run();
+
+
testRunner.assertAllFlowFilesTransferred(ExtractStructuredBoxFileMetadata.REL_SUCCESS,
1);
+ final MockFlowFile flowFile =
testRunner.getFlowFilesForRelationship(ExtractStructuredBoxFileMetadata.REL_SUCCESS).get(0);
+
+ flowFile.assertAttributeEquals("box.id", TEST_FILE_ID);
+ flowFile.assertAttributeEquals("box.ai.extraction.method",
ExtractionMethod.FIELDS.name());
+ flowFile.assertAttributeEquals("box.ai.completion.reason",
COMPLETION_REASON);
+ flowFile.assertAttributeNotExists("box.ai.template.key");
+
+ assertProvenanceEvent(ProvenanceEventType.ATTRIBUTES_MODIFIED);
+ }
+
+ @Test
+ void testFileNotFoundWithTemplate() {
+ // Simulate a 404 error when processing a template.
+ templateResponseSupplier = (templateKey, fileId) -> {
+ throw new BoxAPIResponseException("Not Found", 404, "Not Found",
null);
+ };
+
+ testRunner.enqueue("test data");
+ testRunner.run();
+
+
testRunner.assertAllFlowFilesTransferred(ExtractStructuredBoxFileMetadata.REL_FILE_NOT_FOUND,
1);
+ final MockFlowFile flowFile =
testRunner.getFlowFilesForRelationship(ExtractStructuredBoxFileMetadata.REL_FILE_NOT_FOUND).get(0);
+ flowFile.assertAttributeEquals(BoxFileAttributes.ERROR_CODE, "404");
+ flowFile.assertAttributeEquals(BoxFileAttributes.ERROR_MESSAGE, "Not
Found [404]");
+ }
+
+ @Test
+ void testFileNotFoundWithFields() {
+ // Simulate a 404 error when processing fields.
+ fieldsInputStreamResponseSupplier = (inputStream) -> {
+ throw new BoxAPIResponseException("Not Found", 404, "Not Found",
null);
+ };
+
+
testRunner.setProperty(ExtractStructuredBoxFileMetadata.EXTRACTION_METHOD,
ExtractionMethod.FIELDS.getValue());
+
testRunner.removeProperty(ExtractStructuredBoxFileMetadata.TEMPLATE_KEY);
+
+ testRunner.enqueue(FIELDS_JSON);
+ testRunner.run();
+
+
testRunner.assertAllFlowFilesTransferred(ExtractStructuredBoxFileMetadata.REL_FILE_NOT_FOUND,
1);
+ final MockFlowFile flowFile =
testRunner.getFlowFilesForRelationship(ExtractStructuredBoxFileMetadata.REL_FILE_NOT_FOUND).get(0);
+ flowFile.assertAttributeEquals(BoxFileAttributes.ERROR_CODE, "404");
+ flowFile.assertAttributeEquals(BoxFileAttributes.ERROR_MESSAGE, "Not
Found [404]");
+ }
+
+ @Test
+ void testTemplateNotFound() {
+ // Simulate a 404 error that indicates the template was not found.
+ templateResponseSupplier = (templateKey, fileId) -> {
+ throw new BoxAPIResponseException("API Error", 404, "Specified
Metadata Template not found", null);
+ };
+
+ testRunner.enqueue("test data");
+ testRunner.run();
+
+
testRunner.assertAllFlowFilesTransferred(ExtractStructuredBoxFileMetadata.REL_TEMPLATE_NOT_FOUND,
1);
+ final MockFlowFile flowFile =
testRunner.getFlowFilesForRelationship(ExtractStructuredBoxFileMetadata.REL_TEMPLATE_NOT_FOUND).get(0);
+ flowFile.assertAttributeEquals(BoxFileAttributes.ERROR_CODE, "404");
+ flowFile.assertAttributeEquals(BoxFileAttributes.ERROR_MESSAGE, "API
Error [404]");
+ }
+
+ @Test
+ void testOtherAPIError() {
+ // Simulate a non-404 error.
+ templateResponseSupplier = (templateKey, fileId) -> {
+ throw new BoxAPIResponseException("Server Error", 500, "Server
Error", null);
+ };
+
+ testRunner.enqueue("test data");
+ testRunner.run();
+
+
testRunner.assertAllFlowFilesTransferred(ExtractStructuredBoxFileMetadata.REL_FAILURE,
1);
+ final MockFlowFile flowFile =
testRunner.getFlowFilesForRelationship(ExtractStructuredBoxFileMetadata.REL_FAILURE).get(0);
+ flowFile.assertAttributeEquals(BoxFileAttributes.ERROR_CODE, "500");
+ flowFile.assertAttributeEquals(BoxFileAttributes.ERROR_MESSAGE,
"Server Error [500]");
+ }
+
+ @Test
+ void testGenericException() {
+ // Simulate a generic runtime exception.
+ templateResponseSupplier = (templateKey, fileId) -> {
+ throw new RuntimeException("Something went wrong");
+ };
+
+ testRunner.enqueue("test data");
+ testRunner.run();
+
+
testRunner.assertAllFlowFilesTransferred(ExtractStructuredBoxFileMetadata.REL_FAILURE,
1);
+ final MockFlowFile flowFile =
testRunner.getFlowFilesForRelationship(ExtractStructuredBoxFileMetadata.REL_FAILURE).get(0);
+ flowFile.assertAttributeEquals(BoxFileAttributes.ERROR_MESSAGE,
"Something went wrong");
+ }
+
+ @Test
+ void testExpressionLanguageWithTemplate() {
+ final Map<String, String> attributes = new HashMap<>();
+ attributes.put("file.id", TEST_FILE_ID);
+ attributes.put("template.key", TEMPLATE_KEY);
+
+ testRunner.setProperty(ExtractStructuredBoxFileMetadata.FILE_ID,
"${file.id}");
+ testRunner.setProperty(ExtractStructuredBoxFileMetadata.TEMPLATE_KEY,
"${template.key}");
+ testRunner.enqueue("test data", attributes);
+ testRunner.run();
+
+
testRunner.assertAllFlowFilesTransferred(ExtractStructuredBoxFileMetadata.REL_SUCCESS,
1);
+ final MockFlowFile flowFile =
testRunner.getFlowFilesForRelationship(ExtractStructuredBoxFileMetadata.REL_SUCCESS).get(0);
+ flowFile.assertAttributeEquals("box.id", TEST_FILE_ID);
+ flowFile.assertAttributeEquals("box.ai.template.key", TEMPLATE_KEY);
+ flowFile.assertAttributeEquals("box.ai.completion.reason",
COMPLETION_REASON);
+ }
+
+ @Test
+ void testExpressionLanguageWithFields() {
+ final Map<String, String> attributes = new HashMap<>();
+ attributes.put("file.id", TEST_FILE_ID);
+
+
testRunner.setProperty(ExtractStructuredBoxFileMetadata.EXTRACTION_METHOD,
ExtractionMethod.FIELDS.getValue());
+
testRunner.removeProperty(ExtractStructuredBoxFileMetadata.TEMPLATE_KEY);
+ testRunner.setProperty(ExtractStructuredBoxFileMetadata.FILE_ID,
"${file.id}");
+ testRunner.enqueue(FIELDS_JSON, attributes);
+ testRunner.run();
+
+
testRunner.assertAllFlowFilesTransferred(ExtractStructuredBoxFileMetadata.REL_SUCCESS,
1);
+ final MockFlowFile flowFile =
testRunner.getFlowFilesForRelationship(ExtractStructuredBoxFileMetadata.REL_SUCCESS).get(0);
+ flowFile.assertAttributeEquals("box.id", TEST_FILE_ID);
+ flowFile.assertAttributeEquals("box.ai.extraction.method",
ExtractionMethod.FIELDS.name());
+ flowFile.assertAttributeEquals("box.ai.completion.reason",
COMPLETION_REASON);
+ }
+
+ @Test
+ void testMalformedJsonFields() {
+ fieldsInputStreamResponseSupplier = (inputStream) -> {
+ throw new RuntimeException("Error parsing JSON fields");
+ };
+
+
testRunner.setProperty(ExtractStructuredBoxFileMetadata.EXTRACTION_METHOD,
ExtractionMethod.FIELDS.getValue());
+
testRunner.removeProperty(ExtractStructuredBoxFileMetadata.TEMPLATE_KEY);
+
+ testRunner.enqueue("{bad json}");
+ testRunner.run();
+
+
testRunner.assertAllFlowFilesTransferred(ExtractStructuredBoxFileMetadata.REL_FAILURE,
1);
+ final MockFlowFile flowFile =
testRunner.getFlowFilesForRelationship(ExtractStructuredBoxFileMetadata.REL_FAILURE).get(0);
+ flowFile.assertAttributeExists(BoxFileAttributes.ERROR_MESSAGE);
+ }
+}
diff --git
a/nifi-extension-bundles/nifi-box-bundle/nifi-box-processors/src/test/java/org/apache/nifi/processors/box/ListBoxFileMetadataInstancesParseJsonTest.java
b/nifi-extension-bundles/nifi-box-bundle/nifi-box-processors/src/test/java/org/apache/nifi/processors/box/ListBoxFileMetadataInstancesParseJsonTest.java
new file mode 100644
index 0000000000..521d664984
--- /dev/null
+++
b/nifi-extension-bundles/nifi-box-bundle/nifi-box-processors/src/test/java/org/apache/nifi/processors/box/ListBoxFileMetadataInstancesParseJsonTest.java
@@ -0,0 +1,138 @@
+/*
+ * 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.box;
+
+import com.eclipsesource.json.Json;
+import com.eclipsesource.json.JsonArray;
+import com.eclipsesource.json.JsonObject;
+import com.eclipsesource.json.JsonValue;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+public class ListBoxFileMetadataInstancesParseJsonTest {
+
+ @Test
+ void testParseString() {
+ String expected = "test string";
+ Object result =
ListBoxFileMetadataInstances.parseJsonValue(Json.value(expected));
+ assertEquals(expected, result);
+
+ // Empty string
+ expected = "";
+ result =
ListBoxFileMetadataInstances.parseJsonValue(Json.value(expected));
+ assertEquals(expected, result);
+ }
+
+ @Test
+ void testParseBoolean() {
+ // Test true
+ boolean expected = true;
+ Object result =
ListBoxFileMetadataInstances.parseJsonValue(Json.value(expected));
+ assertEquals(expected, result);
+
+ // Test false
+ expected = false;
+ result =
ListBoxFileMetadataInstances.parseJsonValue(Json.value(expected));
+ assertEquals(expected, result);
+ }
+
+ @Test
+ void testParseIntegerNumber() {
+ // Integer value
+ long expected = 42;
+ Object result =
ListBoxFileMetadataInstances.parseJsonValue(Json.value(expected));
+ assertEquals(expected, result);
+
+ // Max long value
+ expected = Long.MAX_VALUE;
+ result =
ListBoxFileMetadataInstances.parseJsonValue(Json.value(expected));
+ assertEquals(expected, result);
+
+ // Min long value
+ expected = Long.MIN_VALUE;
+ result =
ListBoxFileMetadataInstances.parseJsonValue(Json.value(expected));
+ assertEquals(expected, result);
+ }
+
+ @Test
+ void testParseDecimalNumber() {
+ // Double without exponent
+ String input = "3.14159";
+ JsonValue jsonValue = Json.parse(input);
+ Object result = ListBoxFileMetadataInstances.parseJsonValue(jsonValue);
+ assertEquals(input, result);
+
+ // Very small number that should be preserved
+ input = "0.0000000001";
+ jsonValue = Json.parse(input);
+ result = ListBoxFileMetadataInstances.parseJsonValue(jsonValue);
+ assertEquals(input, result);
+
+ // Very large number that should be preserved
+ input = "9999999999999999.9999";
+ jsonValue = Json.parse(input);
+ result = ListBoxFileMetadataInstances.parseJsonValue(jsonValue);
+ assertEquals(input, result);
+ }
+
+ @Test
+ void testParseExponentialNumber() {
+ // Scientific notation is converted to plain string format
+ String input = "1.234e5";
+ JsonValue jsonValue = Json.parse(input);
+ Object result = ListBoxFileMetadataInstances.parseJsonValue(jsonValue);
+ assertEquals("123400", result);
+
+ // large exponent
+ input = "1.234e20";
+ jsonValue = Json.parse(input);
+ result = ListBoxFileMetadataInstances.parseJsonValue(jsonValue);
+ assertEquals("123400000000000000000", result);
+
+ // Negative exponent
+ input = "1.234e-5";
+ jsonValue = Json.parse(input);
+ result = ListBoxFileMetadataInstances.parseJsonValue(jsonValue);
+ assertEquals("0.00001234", result);
+ }
+
+ @Test
+ void testParseObjectAndArray() {
+ // JSON objects return their string representation
+ JsonObject jsonObject = Json.object().add("key", "value");
+ Object result =
ListBoxFileMetadataInstances.parseJsonValue(jsonObject);
+ assertEquals(jsonObject.toString(), result);
+
+ // JSON arrays return their string representation
+ JsonArray jsonArray = Json.array().add("item1").add("item2");
+ result = ListBoxFileMetadataInstances.parseJsonValue(jsonArray);
+ assertEquals(jsonArray.toString(), result);
+ }
+
+ @Test
+ void testParseNumberFormatException() {
+ String largeIntegerString = "9999999999999999999"; // Beyond
Long.MAX_VALUE
+ JsonValue jsonValue = Json.parse(largeIntegerString);
+ Object result = ListBoxFileMetadataInstances.parseJsonValue(jsonValue);
+ assertEquals(largeIntegerString, result);
+
+ double doubleValue = 123.456;
+ result =
ListBoxFileMetadataInstances.parseJsonValue(Json.value(doubleValue));
+ assertEquals(String.valueOf(doubleValue), result);
+ }
+}
diff --git
a/nifi-extension-bundles/nifi-box-bundle/nifi-box-processors/src/test/java/org/apache/nifi/processors/box/ListBoxFileMetadataInstancesTest.java
b/nifi-extension-bundles/nifi-box-bundle/nifi-box-processors/src/test/java/org/apache/nifi/processors/box/ListBoxFileMetadataInstancesTest.java
new file mode 100644
index 0000000000..34537adb1b
--- /dev/null
+++
b/nifi-extension-bundles/nifi-box-bundle/nifi-box-processors/src/test/java/org/apache/nifi/processors/box/ListBoxFileMetadataInstancesTest.java
@@ -0,0 +1,165 @@
+/*
+ * 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.box;
+
+import com.box.sdk.BoxAPIException;
+import com.box.sdk.BoxAPIResponseException;
+import com.box.sdk.BoxFile;
+import com.box.sdk.Metadata;
+import com.eclipsesource.json.Json;
+import com.eclipsesource.json.JsonObject;
+import org.apache.nifi.flowfile.attributes.CoreAttributes;
+import org.apache.nifi.util.MockFlowFile;
+import org.apache.nifi.util.TestRunners;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+public class ListBoxFileMetadataInstancesTest extends AbstractBoxFileTest {
+
+ private static final String TEMPLATE_1_ID = "12345";
+ private static final String TEMPLATE_1_NAME = "fileMetadata";
+ private static final String TEMPLATE_1_SCOPE = "enterprise_123";
+ private static final String TEMPLATE_2_ID = "67890";
+ private static final String TEMPLATE_2_NAME = "properties";
+ private static final String TEMPLATE_2_SCOPE = "global";
+
+ @Mock
+ private BoxFile mockBoxFile;
+
+ @Override
+ @BeforeEach
+ void setUp() throws Exception {
+ final ListBoxFileMetadataInstances testSubject = new
ListBoxFileMetadataInstances() {
+ @Override
+ BoxFile getBoxFile(String fileId) {
+ return mockBoxFile;
+ }
+ };
+
+ testRunner = TestRunners.newTestRunner(testSubject);
+ super.setUp();
+ }
+
+ @Test
+ void testSuccessfulMetadataRetrieval() {
+ final JsonObject metadataJson1 = Json.object()
+ .add("$id", TEMPLATE_1_ID)
+ .add("$type", "fileMetadata-123")
+ .add("$parent", "file_" + TEST_FILE_ID)
+ .add("$template", TEMPLATE_1_NAME)
+ .add("$scope", TEMPLATE_1_SCOPE)
+ .add("fileName", "document.pdf")
+ .add("fileExtension", "pdf");
+ final Metadata metadata1 = new Metadata(metadataJson1);
+
+ final JsonObject metadataJson2 = Json.object()
+ .add("$id", TEMPLATE_2_ID)
+ .add("$type", "properties-123456")
+ .add("$parent", "file_" + TEST_FILE_ID)
+ .add("$template", TEMPLATE_2_NAME)
+ .add("$scope", TEMPLATE_2_SCOPE)
+ .add("Test Number", Json.NULL)
+ .add("Title", "Test Document")
+ .add("Author", "John Doe");
+ final Metadata metadata2 = new Metadata(metadataJson2);
+
+ final List<Metadata> metadataList = List.of(metadata1, metadata2);
+
+ doReturn(metadataList).when(mockBoxFile).getAllMetadata();
+
+ testRunner.setProperty(ListBoxFileMetadataInstances.FILE_ID,
TEST_FILE_ID);
+ testRunner.enqueue(new byte[0]);
+ testRunner.run();
+
testRunner.assertAllFlowFilesTransferred(ListBoxFileMetadataInstances.REL_SUCCESS,
1);
+
+ final MockFlowFile flowFile =
testRunner.getFlowFilesForRelationship(ListBoxFileMetadataInstances.REL_SUCCESS).getFirst();
+ flowFile.assertAttributeEquals("box.id", TEST_FILE_ID);
+ flowFile.assertAttributeEquals("record.count", "2");
+ flowFile.assertAttributeEquals(CoreAttributes.MIME_TYPE.key(),
"application/json");
+ flowFile.assertAttributeEquals("box.metadata.instances.names",
"fileMetadata,properties");
+ flowFile.assertAttributeEquals("box.metadata.instances.count", "2");
+
+ final String content = new String(flowFile.toByteArray());
+ assertTrue(content.contains("\"$id\":\"" + TEMPLATE_1_ID + "\""));
+ assertTrue(content.contains("\"$template\":\"" + TEMPLATE_1_NAME +
"\""));
+ assertTrue(content.contains("\"$scope\":\"" + TEMPLATE_1_SCOPE +
"\""));
+ assertTrue(content.contains("\"$parent\":\"file_" + TEST_FILE_ID +
"\""));
+ assertTrue(content.contains("\"fileName\":\"document.pdf\""));
+ assertTrue(content.contains("\"fileExtension\":\"pdf\""));
+
+ assertTrue(content.contains("\"$id\":\"" + TEMPLATE_2_ID + "\""));
+ assertTrue(content.contains("\"$template\":\"" + TEMPLATE_2_NAME +
"\""));
+ assertTrue(content.contains("\"$scope\":\"" + TEMPLATE_2_SCOPE +
"\""));
+ assertTrue(content.contains("\"$parent\":\"file_" + TEST_FILE_ID +
"\""));
+ assertTrue(content.contains("\"Title\":\"Test Document\""));
+ assertTrue(content.contains("\"Author\":\"John Doe\""));
+ }
+
+ @Test
+ void testNoMetadata() {
+ when(mockBoxFile.getAllMetadata()).thenReturn(new ArrayList<>());
+ testRunner.setProperty(ListBoxFileMetadataInstances.FILE_ID,
TEST_FILE_ID);
+ testRunner.enqueue(new byte[0]);
+ testRunner.run();
+
+
testRunner.assertAllFlowFilesTransferred(ListBoxFileMetadataInstances.REL_SUCCESS,
1);
+ final MockFlowFile flowFile =
testRunner.getFlowFilesForRelationship(ListBoxFileMetadataInstances.REL_SUCCESS).getFirst();
+ flowFile.assertAttributeEquals("box.id", TEST_FILE_ID);
+ flowFile.assertAttributeEquals("box.metadata.instances.count", "0");
+ }
+
+ @Test
+ void testFileNotFound() {
+ final BoxAPIResponseException mockException = new
BoxAPIResponseException("API Error", 404, "Box File Not Found", null);
+ doThrow(mockException).when(mockBoxFile).getAllMetadata();
+
+ testRunner.setProperty(ListBoxFileMetadataInstances.FILE_ID,
TEST_FILE_ID);
+ testRunner.enqueue(new byte[0]);
+ testRunner.run();
+
+
testRunner.assertAllFlowFilesTransferred(ListBoxFileMetadataInstances.REL_NOT_FOUND,
1);
+ final MockFlowFile flowFile =
testRunner.getFlowFilesForRelationship(ListBoxFileMetadataInstances.REL_NOT_FOUND).getFirst();
+ flowFile.assertAttributeEquals(BoxFileAttributes.ERROR_CODE, "404");
+ flowFile.assertAttributeEquals(BoxFileAttributes.ERROR_MESSAGE, "API
Error [404]");
+ }
+
+ @Test
+ void testBoxApiException() {
+ final BoxAPIException mockException = new BoxAPIException("General API
Error", 500, "Unexpected Error");
+ doThrow(mockException).when(mockBoxFile).getAllMetadata();
+
+ testRunner.setProperty(ListBoxFileMetadataInstances.FILE_ID,
TEST_FILE_ID);
+ testRunner.enqueue(new byte[0]);
+ testRunner.run();
+
+
testRunner.assertAllFlowFilesTransferred(ListBoxFileMetadataInstances.REL_FAILURE,
1);
+ final MockFlowFile flowFile =
testRunner.getFlowFilesForRelationship(ListBoxFileMetadataInstances.REL_FAILURE).getFirst();
+ flowFile.assertAttributeEquals(BoxFileAttributes.ERROR_MESSAGE,
"General API Error\nUnexpected Error");
+ }
+}
diff --git
a/nifi-extension-bundles/nifi-box-bundle/nifi-box-processors/src/test/java/org/apache/nifi/processors/box/UpdateBoxFileMetadataInstanceTest.java
b/nifi-extension-bundles/nifi-box-bundle/nifi-box-processors/src/test/java/org/apache/nifi/processors/box/UpdateBoxFileMetadataInstanceTest.java
new file mode 100644
index 0000000000..0f7437083b
--- /dev/null
+++
b/nifi-extension-bundles/nifi-box-bundle/nifi-box-processors/src/test/java/org/apache/nifi/processors/box/UpdateBoxFileMetadataInstanceTest.java
@@ -0,0 +1,388 @@
+/*
+ * 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.box;
+
+import com.box.sdk.BoxAPIResponseException;
+import com.box.sdk.BoxFile;
+import com.box.sdk.Metadata;
+import com.eclipsesource.json.Json;
+import com.eclipsesource.json.JsonArray;
+import org.apache.nifi.json.JsonTreeReader;
+import org.apache.nifi.reporting.InitializationException;
+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 org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.verify;
+
+@ExtendWith(MockitoExtension.class)
+public class UpdateBoxFileMetadataInstanceTest extends AbstractBoxFileTest {
+
+ private static final String TEMPLATE_NAME = "fileProperties";
+ private static final String TEMPLATE_SCOPE = "enterprise";
+
+ @Mock
+ private BoxFile mockBoxFile;
+
+ @Mock
+ private Metadata mockMetadata;
+
+ private UpdateBoxFileMetadataInstance createTestSubject() {
+ return new UpdateBoxFileMetadataInstance() {
+ @Override
+ BoxFile getBoxFile(final String fileId) {
+ return mockBoxFile;
+ }
+
+ @Override
+ Metadata getMetadata(final BoxFile boxFile,
+ final String templateKey) {
+ return mockMetadata;
+ }
+ };
+ }
+
+ @Override
+ @BeforeEach
+ void setUp() throws Exception {
+ testRunner = TestRunners.newTestRunner(createTestSubject());
+ super.setUp();
+
+ configureJsonRecordReader(testRunner);
+
+ testRunner.setProperty(UpdateBoxFileMetadataInstance.FILE_ID,
TEST_FILE_ID);
+ testRunner.setProperty(UpdateBoxFileMetadataInstance.TEMPLATE_KEY,
TEMPLATE_NAME);
+ testRunner.setProperty(UpdateBoxFileMetadataInstance.RECORD_READER,
"json-reader");
+
+ lenient().when(mockMetadata.getScope()).thenReturn(TEMPLATE_SCOPE);
+
lenient().when(mockMetadata.getTemplateName()).thenReturn(TEMPLATE_NAME);
+
lenient().when(mockBoxFile.getMetadata(TEMPLATE_NAME)).thenReturn(mockMetadata);
+
lenient().when(mockMetadata.getPropertyPaths()).thenReturn(List.of("/temp1",
"/test"));
+
lenient().when(mockMetadata.getValue("/temp1")).thenReturn(Json.value("value1"));
+
lenient().when(mockMetadata.getValue("/test")).thenReturn(Json.value("test"));
+ }
+
+ private void configureJsonRecordReader(TestRunner runner) throws
InitializationException {
+ final JsonTreeReader readerService = new JsonTreeReader();
+ runner.addControllerService("json-reader", readerService);
+ runner.enableControllerService(readerService);
+ }
+
+ @Test
+ void testSuccessfulMetadataUpdate() {
+ final JsonArray operationsArray = new JsonArray();
+ operationsArray.add("someOperation");
+
lenient().when(mockMetadata.getOperations()).thenReturn(operationsArray);
+
+ final String inputJson = """
+ {
+ "audience": "internal",
+ "documentType": "Q1 plans",
+ "competitiveDocument": "no",
+ "status": "active",
+ "author": "Jones"
+ }""";
+
+ testRunner.enqueue(inputJson);
+ testRunner.run();
+
+ verify(mockBoxFile).updateMetadata(any(Metadata.class));
+
+
testRunner.assertAllFlowFilesTransferred(UpdateBoxFileMetadataInstance.REL_SUCCESS,
1);
+ final MockFlowFile flowFile =
testRunner.getFlowFilesForRelationship(UpdateBoxFileMetadataInstance.REL_SUCCESS).getFirst();
+
+ flowFile.assertAttributeEquals("box.id", TEST_FILE_ID);
+ flowFile.assertAttributeEquals("box.template.key", TEMPLATE_NAME);
+ }
+
+ @Test
+ void testEmptyInput() {
+ final String inputJson = "{}";
+
+ testRunner.enqueue(inputJson);
+ testRunner.run();
+
+
testRunner.assertAllFlowFilesTransferred(UpdateBoxFileMetadataInstance.REL_FAILURE,
1);
+ final MockFlowFile flowFile =
testRunner.getFlowFilesForRelationship(UpdateBoxFileMetadataInstance.REL_FAILURE).getFirst();
+ flowFile.assertAttributeExists("error.message");
+ }
+
+ @Test
+ void testFileNotFound() throws Exception {
+ testRunner = TestRunners.newTestRunner(new
UpdateBoxFileMetadataInstance() {
+ @Override
+ BoxFile getBoxFile(final String fileId) {
+ throw new BoxAPIResponseException("API Error", 404, "Box File
Not Found", null);
+ }
+ });
+ super.setUp();
+ configureJsonRecordReader(testRunner);
+
+ testRunner.setProperty(UpdateBoxFileMetadataInstance.FILE_ID,
TEST_FILE_ID);
+ testRunner.setProperty(UpdateBoxFileMetadataInstance.TEMPLATE_KEY,
TEMPLATE_NAME);
+ testRunner.setProperty(UpdateBoxFileMetadataInstance.RECORD_READER,
"json-reader");
+
+ final String inputJson = """
+ {
+ "audience": "internal",
+ "documentType": "Q1 plans"
+ }""";
+
+ testRunner.enqueue(inputJson);
+ testRunner.run();
+
+
testRunner.assertAllFlowFilesTransferred(UpdateBoxFileMetadataInstance.REL_FILE_NOT_FOUND,
1);
+ final MockFlowFile flowFile =
testRunner.getFlowFilesForRelationship(UpdateBoxFileMetadataInstance.REL_FILE_NOT_FOUND).getFirst();
+ flowFile.assertAttributeEquals(BoxFileAttributes.ERROR_CODE, "404");
+ }
+
+ @Test
+ void testTemplateNotFound() throws Exception {
+ testRunner = TestRunners.newTestRunner(new
UpdateBoxFileMetadataInstance() {
+ @Override
+ Metadata getMetadata(final BoxFile boxFile,
+ final String templateKey) {
+ throw new BoxAPIResponseException("API Error", 404, "Specified
Metadata Template not found", null);
+ }
+ });
+ super.setUp();
+ configureJsonRecordReader(testRunner);
+
+ testRunner.setProperty(UpdateBoxFileMetadataInstance.FILE_ID,
TEST_FILE_ID);
+ testRunner.setProperty(UpdateBoxFileMetadataInstance.TEMPLATE_KEY,
TEMPLATE_NAME);
+ testRunner.setProperty(UpdateBoxFileMetadataInstance.RECORD_READER,
"json-reader");
+
+ final String inputJson = """
+ {
+ "audience": "internal",
+ "documentType": "Q1 plans"
+ }""";
+
+ testRunner.enqueue(inputJson);
+ testRunner.run();
+
+
testRunner.assertAllFlowFilesTransferred(UpdateBoxFileMetadataInstance.REL_TEMPLATE_NOT_FOUND,
1);
+ final MockFlowFile flowFile =
testRunner.getFlowFilesForRelationship(UpdateBoxFileMetadataInstance.REL_TEMPLATE_NOT_FOUND).getFirst();
+ flowFile.assertAttributeEquals(BoxFileAttributes.ERROR_CODE, "404");
+ flowFile.assertAttributeEquals(BoxFileAttributes.ERROR_MESSAGE, "API
Error [404]");
+ }
+
+ @Test
+ void testNullValues() {
+ JsonArray operationsArray = new JsonArray();
+ operationsArray.add("someOperation");
+
lenient().when(mockMetadata.getOperations()).thenReturn(operationsArray);
+
+ final String inputJson = """
+ {
+ "audience": "internal",
+ "documentType": "Q1 plans",
+ "status": "active"
+ }""";
+
+ testRunner.enqueue(inputJson);
+ testRunner.run();
+
+ // Verify the mockBoxFile.updateMetadata was called
+ verify(mockBoxFile).updateMetadata(any(Metadata.class));
+
testRunner.assertAllFlowFilesTransferred(UpdateBoxFileMetadataInstance.REL_SUCCESS,
1);
+ }
+
+ @Test
+ void testExpressionLanguage() {
+ final JsonArray operationsArray = new JsonArray();
+ operationsArray.add("someOperation");
+
lenient().when(mockMetadata.getOperations()).thenReturn(operationsArray);
+
+ final Map<String, String> attributes = new HashMap<>();
+ attributes.put("file.id", TEST_FILE_ID);
+ attributes.put("template.key", TEMPLATE_NAME);
+
+ testRunner.setProperty(UpdateBoxFileMetadataInstance.FILE_ID,
"${file.id}");
+ testRunner.setProperty(UpdateBoxFileMetadataInstance.TEMPLATE_KEY,
"${template.key}");
+
+ final String inputJson = """
+ {
+ "audience": "internal",
+ "documentType": "Q1 plans"
+ }""";
+
+ testRunner.enqueue(inputJson, attributes);
+ testRunner.run();
+
+ verify(mockBoxFile).updateMetadata(any(Metadata.class));
+
+ assertEquals(TEST_FILE_ID,
+
testRunner.getProcessContext().getProperty(UpdateBoxFileMetadataInstance.FILE_ID).evaluateAttributeExpressions(attributes).getValue());
+ assertEquals(TEMPLATE_NAME,
+
testRunner.getProcessContext().getProperty(UpdateBoxFileMetadataInstance.TEMPLATE_KEY).evaluateAttributeExpressions(attributes).getValue());
+
+
testRunner.assertAllFlowFilesTransferred(UpdateBoxFileMetadataInstance.REL_SUCCESS,
1);
+ }
+
+ @Test
+ void testMetadataPatchChanges() {
+ final JsonArray operationsArray = new JsonArray();
+ operationsArray.add("someOperation");
+
lenient().when(mockMetadata.getOperations()).thenReturn(operationsArray);
+
+ // This tests the core functionality where we replace the entire state
+ // Original metadata has "/temp1":"value1" and "/test":"test"
+ // New metadata will have "/temp2":"value2" and "/test":"updated"
+ // We expect: temp1 to be removed, temp2 to be added, test to be
replaced
+ final String inputJson = """
+ {
+ "temp2": "value2",
+ "test": "updated"
+ }""";
+
+ testRunner.enqueue(inputJson);
+ testRunner.run();
+
+ // Verify the correct operations were done on the mockMetadata
+ verify(mockMetadata).remove("/temp1"); // Should remove temp1
+ verify(mockMetadata).add("/temp2", "value2"); // Should add temp2
+ verify(mockMetadata).replace("/test", "updated"); // Should update
test
+ verify(mockBoxFile).updateMetadata(any(Metadata.class));
+
+
testRunner.assertAllFlowFilesTransferred(UpdateBoxFileMetadataInstance.REL_SUCCESS,
1);
+ }
+
+ @Test
+ void testAddingDifferentDataTypes() {
+ final JsonArray operationsArray = new JsonArray();
+ operationsArray.add("someOperation");
+
lenient().when(mockMetadata.getOperations()).thenReturn(operationsArray);
+
+ // Clear the property paths to simulate no existing metadata
+ lenient().when(mockMetadata.getPropertyPaths()).thenReturn(List.of());
+
+ final String inputJson = """
+ {
+ "stringField": "text value",
+ "numberField": 42,
+ "doubleField": 42.5,
+ "booleanField": true,
+ "listField": ["item1", "item2", "item3"],
+ "emptyListField": []
+ }""";
+
+ testRunner.enqueue(inputJson);
+ testRunner.run();
+
+ // Verify all fields were added with correct types
+ verify(mockMetadata).add("/stringField", "text value");
+ verify(mockMetadata).add("/numberField", 42.0); // Numbers are stored
as doubles
+ verify(mockMetadata).add("/doubleField", 42.5);
+ verify(mockMetadata).add("/booleanField", "true"); // Booleans are
stored as strings
+ // We need to use doAnswer/when to capture and verify list fields
being added, but this is simpler
+
+ verify(mockBoxFile).updateMetadata(any(Metadata.class));
+
testRunner.assertAllFlowFilesTransferred(UpdateBoxFileMetadataInstance.REL_SUCCESS,
1);
+ }
+
+ @Test
+ void testUpdateExistingFieldsWithDifferentTypes() {
+ final JsonArray operationsArray = new JsonArray();
+ operationsArray.add("someOperation");
+
lenient().when(mockMetadata.getOperations()).thenReturn(operationsArray);
+ lenient().when(mockMetadata.getPropertyPaths()).thenReturn(List.of(
+ "/stringField", "/numberField", "/listField"
+ ));
+
+
lenient().when(mockMetadata.getValue("/stringField")).thenReturn(Json.value("old
value"));
+
lenient().when(mockMetadata.getValue("/numberField")).thenReturn(Json.value(10.0));
+ JsonArray oldList = new JsonArray();
+ oldList.add("old1");
+ oldList.add("old2");
+
lenient().when(mockMetadata.getValue("/listField")).thenReturn(oldList);
+
+ final String inputJson = """
+ {
+ "stringField": "new value",
+ "numberField": 20,
+ "listField": ["new1", "new2", "new3"]
+ }""";
+
+ testRunner.enqueue(inputJson);
+ testRunner.run();
+
+ // Verify fields were replaced with new values
+ verify(mockMetadata).replace("/stringField", "new value");
+ verify(mockMetadata).replace("/numberField", 20.0);
+ verify(mockBoxFile).updateMetadata(any(Metadata.class));
+
testRunner.assertAllFlowFilesTransferred(UpdateBoxFileMetadataInstance.REL_SUCCESS,
1);
+ }
+
+ @Test
+ void testNoUpdateWhenValuesUnchanged() {
+ final JsonArray operationsArray = new JsonArray();
+ operationsArray.add("someOperation");
+
lenient().when(mockMetadata.getOperations()).thenReturn(operationsArray);
+
+ // Set up existing fields
+ lenient().when(mockMetadata.getPropertyPaths()).thenReturn(List.of(
+ "/unchangedField", "/unchangedNumber"
+ ));
+
+ // Set up current values
+
lenient().when(mockMetadata.getValue("/unchangedField")).thenReturn(Json.value("same
value"));
+
lenient().when(mockMetadata.getValue("/unchangedNumber")).thenReturn(Json.value(42.0));
+
+ final String inputJson = """
+ {
+ "unchangedField": "same value",
+ "unchangedNumber": 42
+ }""";
+
+ testRunner.enqueue(inputJson);
+ testRunner.run();
+
+
testRunner.assertAllFlowFilesTransferred(UpdateBoxFileMetadataInstance.REL_SUCCESS,
1);
+ verify(mockBoxFile).updateMetadata(any(Metadata.class));
+ }
+
+ @Test
+ void testMixedListHandling() {
+ final JsonArray operationsArray = new JsonArray();
+ operationsArray.add("someOperation");
+
lenient().when(mockMetadata.getOperations()).thenReturn(operationsArray);
+ lenient().when(mockMetadata.getPropertyPaths()).thenReturn(List.of());
+
+ final String inputJson = """
+ {
+ "mixedList": ["string", 42, true, null, 3.14]
+ }""";
+
+ testRunner.enqueue(inputJson);
+ testRunner.run();
+ verify(mockBoxFile).updateMetadata(any(Metadata.class));
+
testRunner.assertAllFlowFilesTransferred(UpdateBoxFileMetadataInstance.REL_SUCCESS,
1);
+ }
+}