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);
+    }
+}


Reply via email to