org.apache.taverna.databundle.DataBundles.java Source code

Java tutorial

Introduction

Here is the source code for org.apache.taverna.databundle.DataBundles.java

Source

/*
 * 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.taverna.databundle;

import static java.nio.file.Files.createDirectories;
import static java.nio.file.Files.delete;
import static java.nio.file.Files.isDirectory;
import static java.nio.file.Files.isRegularFile;
import static java.nio.file.Files.newDirectoryStream;
import static java.nio.file.Files.newInputStream;
import static java.nio.file.Files.newOutputStream;
import static java.nio.file.Files.readAllLines;
import static java.nio.file.Files.write;
import static java.nio.file.StandardOpenOption.CREATE;
import static java.nio.file.StandardOpenOption.TRUNCATE_EXISTING;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.UncheckedIOException;
import java.lang.reflect.Array;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;
import java.net.URLStreamHandler;
import java.nio.charset.Charset;
import java.nio.file.DirectoryIteratorException;
import java.nio.file.DirectoryStream;
import java.nio.file.DirectoryStream.Filter;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collection;
import java.util.EnumSet;
import java.util.List;
import java.util.NavigableMap;
import java.util.TreeMap;
import java.util.UUID;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;

import org.apache.taverna.databundle.DataBundles.ResolveOptions;
import org.apache.taverna.robundle.Bundle;
import org.apache.taverna.robundle.Bundles;
import org.apache.taverna.scufl2.api.container.WorkflowBundle;
import org.apache.taverna.scufl2.api.io.ReaderException;
import org.apache.taverna.scufl2.api.io.WorkflowBundleIO;
import org.apache.taverna.scufl2.api.io.WriterException;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

/**
 * Utility functions for dealing with data bundles.
 * <p>
 * The style of using this class is similar to that of {@link Files}. In fact, a
 * data bundle is implemented as a set of {@link Path}s.
 * 
 */
public class DataBundles extends Bundles {
    private static final class OBJECT_MAPPER {
        // Lazy initialization of singleton
        private static final ObjectMapper instance = new ObjectMapper();
    }

    protected static final class ExtensionIgnoringFilter implements Filter<Path> {
        private final String fname;

        private ExtensionIgnoringFilter(Path file) {
            this.fname = filenameWithoutExtension(file);
        }

        @Override
        public boolean accept(Path entry) throws IOException {
            return fname.equals(filenameWithoutExtension(entry));
        }
    }

    private static WorkflowBundleIO wfBundleIO;

    private static Logger logger = Logger.getLogger(DataBundles.class.getCanonicalName());

    private static final String WFBUNDLE_CONTENT_TYPE = "application/vnd.taverna.scufl2.workflow-bundle";
    private static final String WFDESC_TURTLE = "text/vnd.wf4ever.wfdesc+turtle";
    private static final String WORKFLOW = "workflow";
    private static final String DOT_WFDESC_TTL = ".wfdesc.ttl";
    private static final String DOT_WFBUNDLE = ".wfbundle";
    private static final String WORKFLOWRUN_PROV_TTL = "workflowrun.prov.ttl";
    private static final String WORKFLOWRUN_JSON = "workflowrun.json";
    private static final String DOT_ERR = ".err";
    private static final String INPUTS = "inputs";
    private static final String INTERMEDIATES = "intermediates";
    private static final String OUTPUTS = "outputs";
    private static final Charset UTF8 = Charset.forName("UTF-8");

    private static Path anyExtension(Path path) throws IOException {
        return anyExtension(path.getParent(), path.getFileName().toString());
    }

    private static Path anyExtension(Path directory, String fileName) throws IOException {
        Path path = directory.resolve(fileName);

        // Prefer the fileName as it is
        if (Files.exists(path))
            return path;
        // Strip any existing extension
        String fileNameNoExt = filenameWithoutExtension(path);
        Path withoutExt = path.resolveSibling(fileNameNoExt);
        if (Files.exists(withoutExt))
            return withoutExt;

        // Check directory for path.*
        for (Path p : newDirectoryStream(directory, fileNameNoExt + ".*"))
            /*
             * We'll just return the first one
             * 
             * TODO: Should we fail if there's more than one?
             */
            return p;

        /*
         * Nothing? Then let's give the existing one; perhaps it is to be
         * created.
         */
        return path;
    }

    private static void checkExistingAnyExtension(Path path) throws IOException, FileAlreadyExistsException {
        Path existing = anyExtension(path);
        if (!path.equals(existing))
            throw new FileAlreadyExistsException(existing.toString());
    }

    public static void createList(Path path) throws IOException {
        checkExistingAnyExtension(path);
        Files.createDirectories(path);
    }

    public static void deleteAllExtensions(final Path file) throws IOException {
        Filter<Path> filter = new ExtensionIgnoringFilter(file);
        try (DirectoryStream<Path> ds = newDirectoryStream(file.getParent(), filter)) {
            for (Path p : ds)
                deleteRecursively(p);
        }
    }

    protected static String filenameWithoutExtension(Path entry) {
        String fileName = entry.getFileName().toString();
        int lastDot = fileName.lastIndexOf(".");
        if (lastDot < 0)
            return fileName.replace("/", "");
        return fileName.substring(0, lastDot);
    }

    public static ErrorDocument getError(Path path) throws IOException {
        if (path == null)
            return null;

        Path errorPath = withExtension(path, DOT_ERR);
        List<String> errorList = readAllLines(errorPath, UTF8);
        int split = errorList.indexOf("");
        if (split == -1 || errorList.size() <= split)
            throw new IOException("Invalid error document: " + errorPath);

        ErrorDocument errorDoc = new ErrorDocument();

        for (String cause : errorList.subList(0, split))
            errorDoc.getCausedBy().add(path.resolveSibling(cause));

        errorDoc.setMessage(errorList.get(split + 1));

        StringBuilder errorTrace = new StringBuilder();
        for (String line : errorList.subList(split + 2, errorList.size())) {
            errorTrace.append(line);
            errorTrace.append("\n");
        }
        if (errorTrace.length() > 0)
            // Delete last \n
            errorTrace.deleteCharAt(errorTrace.length() - 1);
        errorDoc.setTrace(errorTrace.toString());
        return errorDoc;
    }

    public static Path getInputs(Bundle dataBundle) throws IOException {
        Path inputs = dataBundle.getRoot().resolve(INPUTS);
        createDirectories(inputs);
        return inputs;
    }

    private static long getEntryNumber(Path entry) throws NumberFormatException {
        String name = filenameWithoutExtension(entry);
        return Long.parseLong(name);
    }

    public static List<Path> getList(Path list) throws IOException {
        if (list == null)
            return null;
        List<Path> paths = new ArrayList<>();
        try (DirectoryStream<Path> ds = newDirectoryStream(list)) {
            for (Path entry : ds)
                try {
                    long entryNum = getEntryNumber(entry);
                    while (paths.size() <= entryNum)
                        // Fill any gaps
                        paths.add(null);
                    // NOTE: Don't use add() as these could come in any order!
                    paths.set((int) entryNum, entry);
                } catch (NumberFormatException ex) {
                }
        } catch (DirectoryIteratorException ex) {
            throw ex.getCause();
        }
        return paths;
    }

    public static Path getListItem(Path list, long position) throws IOException {
        if (position < 0)
            throw new IllegalArgumentException("Position must be 0 or more, not: " + position);
        return anyExtension(list, Long.toString(position));
    }

    public static Path getOutputs(Bundle dataBundle) throws IOException {
        Path inputs = dataBundle.getRoot().resolve(OUTPUTS);
        createDirectories(inputs);
        return inputs;
    }

    public static Path getPort(Path map, String portName) throws IOException {
        Files.createDirectories(map);
        return anyExtension(map, portName);
    }

    public static NavigableMap<String, Path> getPorts(Path path) throws IOException {
        NavigableMap<String, Path> ports = new TreeMap<>();
        try (DirectoryStream<Path> ds = newDirectoryStream(path)) {
            for (Path p : ds)
                ports.put(filenameWithoutExtension(p), p);
        }
        return ports;
    }

    public static boolean hasInputs(Bundle dataBundle) {
        Path inputs = dataBundle.getRoot().resolve(INPUTS);
        return isDirectory(inputs);
    }

    public static boolean hasOutputs(Bundle dataBundle) {
        Path outputs = dataBundle.getRoot().resolve(OUTPUTS);
        return isDirectory(outputs);
    }

    public static boolean isError(Path path) {
        return isRegularFile(withExtension(path, DOT_ERR));
    }

    public static boolean isList(Path path) {
        return isDirectory(path);
    }

    public static boolean isMissing(Path item) {
        return Bundles.isMissing(item) && !isError(item);
    }

    public static boolean isValue(Path item) {
        return !isError(item) && Bundles.isValue(item);
    }

    public static Path newListItem(Path list) throws IOException {
        createList(list);
        return list.resolve(Long.toString(getListSize(list)));
    }

    public static Path setError(Path path, ErrorDocument error) throws IOException {
        return setError(path, error.getMessage(), error.getTrace(),
                error.getCausedBy().toArray(new Path[error.getCausedBy().size()]));
    }

    public static Path setError(Path errorPath, String message, String trace, Path... causedBy) throws IOException {
        errorPath = withExtension(errorPath, DOT_ERR);
        // Silly \n-based format
        List<String> errorDoc = new ArrayList<>();
        for (Path cause : causedBy) {
            Path relCause = errorPath.getParent().relativize(cause);
            errorDoc.add(relCause.toString());
        }
        errorDoc.add(""); // Our magic separator
        errorDoc.add(message);
        errorDoc.add(trace);
        checkExistingAnyExtension(errorPath);
        write(errorPath, errorDoc, UTF8, TRUNCATE_EXISTING, CREATE);
        return errorPath;
    }

    public static Path setReference(Path path, URI reference) throws IOException {
        path = withExtension(path, DOT_URL);
        checkExistingAnyExtension(path);
        return Bundles.setReference(path, reference);
    }

    public static void setStringValue(Path path, String string) throws IOException {
        checkExistingAnyExtension(path);
        Bundles.setStringValue(path, string);
    }

    protected static Path withExtension(Path path, String extension) {
        String filename = path.getFileName().toString();
        return path.resolveSibling(withExtensionFilename(filename, extension));
    }

    protected static String withExtensionFilename(String filename, String extension) {
        if (!extension.isEmpty() && !extension.startsWith("."))
            throw new IllegalArgumentException("Extension must be empty or start with .");
        if (!extension.isEmpty() && filename.toLowerCase().endsWith(extension.toLowerCase()))
            return filename;
        // Everything after the last . - or just the end
        return filename.replaceFirst("(\\.[^.]*)?$", extension);
    }

    public static Path getWorkflowRunProvenance(Bundle dataBundle) {
        return dataBundle.getRoot().resolve(WORKFLOWRUN_PROV_TTL);
    }

    public static Path getWorkflowRunReport(Bundle dataBundle) {
        return dataBundle.getRoot().resolve(WORKFLOWRUN_JSON);
    }

    public static JsonNode getWorkflowRunReportAsJson(Bundle dataBundle) throws IOException {
        Path path = getWorkflowRunReport(dataBundle);
        try (InputStream jsonIn = newInputStream(path)) {
            return OBJECT_MAPPER.instance.readTree(jsonIn);
        }
    }

    public static void setWorkflowRunReport(Bundle dataBundle, JsonNode workflowRunReport) throws IOException {
        Path path = getWorkflowRunReport(dataBundle);
        try (OutputStream out = newOutputStream(path)) {
            OBJECT_MAPPER.instance.writeValue(out, workflowRunReport);
        }
    }

    public static Path getWorkflow(Bundle dataBundle) throws IOException {
        return anyExtension(dataBundle.getRoot(), WORKFLOW);
    }

    public static Path getWorkflowDescription(Bundle dataBundle) throws IOException {
        Path annotations = getAnnotations(dataBundle);
        return annotations.resolve(WORKFLOW + DOT_WFDESC_TTL);
    }

    public static void setWorkflowBundle(Bundle dataBundle, WorkflowBundle wfBundle) throws IOException {
        Path bundlePath = withExtension(getWorkflow(dataBundle), DOT_WFBUNDLE);
        checkExistingAnyExtension(bundlePath);

        // TODO: Save as nested folder?
        try (OutputStream outputStream = newOutputStream(bundlePath)) {
            getWfBundleIO().writeBundle(wfBundle, outputStream, WFBUNDLE_CONTENT_TYPE);
        } catch (WriterException e) {
            throw new IOException("Can't write workflow bundle to: " + bundlePath, e);
        }

        // wfdesc
        Path wfdescPath = getWorkflowDescription(dataBundle);
        try (OutputStream outputStream = newOutputStream(wfdescPath)) {
            getWfBundleIO().writeBundle(wfBundle, outputStream, WFDESC_TURTLE);
        } catch (IllegalArgumentException | WriterException e) {
            logger.log(Level.WARNING, "Can't write wfdesc to: " + bundlePath, e);
            delete(wfdescPath);
            // throw new IOException("Can't write wfdesc to: " + bundlePath, e);
        }
    }

    public static WorkflowBundle getWorkflowBundle(Bundle dataBundle) throws ReaderException, IOException {
        Path wf = getWorkflow(dataBundle);
        // String type = Files.probeContentType(wf);
        return getWfBundleIO().readBundle(newInputStream(wf), null);
    }

    public static Path getIntermediates(Bundle dataBundle) throws IOException {
        Path intermediates = dataBundle.getRoot().resolve(INTERMEDIATES);
        createDirectories(intermediates);
        return intermediates;
    }

    public static Path getIntermediate(Bundle dataBundle, UUID uuid) throws IOException {
        String fileName = uuid.toString();
        Path intermediates = getIntermediates(dataBundle);
        // Folder is named after first 2 characters of UUID
        Path folder = intermediates.resolve(fileName.substring(0, 2));
        createDirectories(folder);
        return anyExtension(folder, fileName);
    }

    public static long getListSize(Path list) throws IOException {
        // Should fail if list is not a directory
        try (DirectoryStream<Path> ds = newDirectoryStream(list)) {
            long max = -1L;
            for (Path entry : ds)
                try {
                    long entryNum = getEntryNumber(entry);
                    if (entryNum > max)
                        max = entryNum;
                } catch (NumberFormatException ex) {
                }
            return max + 1;
        } catch (DirectoryIteratorException ex) {
            throw ex.getCause();
        }
    }

    public enum ResolveOptions {
        /**
         * Leaf values are represented as bundle {@link Path}s, except errors as
         * {@link ErrorDocument} and references as {@link URL}. Note that specifying this
         * option does not negate any of the other options like {@link #BYTES}.
         */
        DEFAULT,
        /**
         * Leaf values should be represented as a {@link String} (NOTE: This won't work well if the path is a binary)
         */
        STRING,
        /**
         * Leaf values should be represented as a <code>byte[]</code>
         */
        BYTES,
        /**
         * Leaf values should always be represented as {@link URI}s (except errors)
         */
        URI,
        /**
         * Leaf values should be represented as bundle {@link Path}s (even if they are errors)
         */
        PATH,
        /**
         * Replace errors with <code>null</code>, or the empty string if {@link #REPLACE_NULL} is also specified.
         */
        REPLACE_ERRORS,
        /**
         * Instead of returning <code>null</code>, return the empty
         * {@link String} "", or empty byte[] if {@link #BYTES} is specified, or
         * the missing path if {@link #PATH} is specified.
         */
        REPLACE_NULL
    }

    /**
     * Deeply resolve a {@link Path} to JVM objects.
     * <p>
     * This method is intended mainly for presentational uses 
     * with a particular input/output port from
     * {@link #getPorts(Path)} or {@link #getPort(Path, String)}.
     * <p>
     * Note that as all lists are resolved deeply (including lists of lists)
     * and when using options {@link ResolveOptions#STRING} or {@link ResolveOptions#BYTES}
     * the full content of the values are read into memory, this 
     * method can be time-consuming.
     * <p>
     * If the path is <code>null</code> or {@link #isMissing(Path)},
     * <code>null</code> is returned, unless the option
     * {@link ResolveOptions#REPLACE_NULL} is specified, which would return the
     * empty String "".
     * <p>
     * If the path {@link #isValue(Path)} and the option
     * {@link ResolveOptions#STRING} is specified, its
     * {@link #getStringValue(Path)} is returned (assuming an UTF-8 encoding).
     * NOTE: Binary formats (e.g. PNG) will NOT be represented correctly read as
     * UTF-8 String and should instead be read directly with
     * {@link Files#newInputStream(Path, java.nio.file.OpenOption...)}. Note
     * that this could consume a large amount of memory as no size checks are
     * performed.
     * <p>
     * If the option {@link ResolveOptions#URI} is specified, all non-missing 
     * non-error leaf values are resolved as a {@link URI}. If the path is a 
     * {@link #isReference(Path)} the URI will be the reference from 
     * {@link #getReference(Path)}, otherwise the URI will  
     * identify a {@link Path} within the current {@link Bundle}.
     * <p>
     * If the path {@link #isValue(Path)} and the option
     * {@link ResolveOptions#BYTES} is specified, the complete content is returned as
     * a <code>byte[]</code>. Note that this could consume a large amount of memory
     * as no size checks are performed.
     * <p>
     * If the path {@link #isError(Path)}, the corresponding
     * {@link ErrorDocument} is returned, except when the option
     * {@link ResolveOptions#REPLACE_ERRORS} is specified, which means errors are
     * returned as <code>null</code> (or <code>""</code> if {@link ResolveOptions#REPLACE_NULL} is also specified).
     * <p>
     * If the path {@link #isReference(Path)} and the option 
     * {@link ResolveOptions#URI} is <strong>not</strong> set, 
     * either a {@link File} or a {@link URL} is returned, 
     * depending on its protocol. If the reference protocol has no
     * corresponding {@link URLStreamHandler}, a {@link URI} is returned
     * instead. 
     * <p>
     * If the path {@link #isList(Path)}, a {@link List} is returned
     * corresponding to resolving the paths from {@link #getList(Path)}. using
     * this method with the same options.
     * <p>
     * If none of the above, the {@link Path} itself is returned. This is 
     * thus the default for non-reference non-error leaf values if neither 
     * {@link ResolveOptions#STRING}, {@link ResolveOptions#BYTES} or
     * {@link ResolveOptions#URI} are specified.
     * To force returning of {@link Path}s for all non-missing leaf values, specify
     * {@link ResolveOptions#PATH};
     * 
     * @param path
     *            Data bundle path to resolve
     * @param options
     *            Resolve options
     * @return <code>null</code>, a {@link String}, {@link ErrorDocument},
     *         {@link URL}, {@link File}, {@link Path} or {@link List}
     *         (containing any of these) depending on the path type and the options.
     * @throws IOException
     *             If the path (or any of the path in a contained list) can't be
     *             accessed
     */
    @SuppressWarnings({ "unchecked", "rawtypes" })
    public static Object resolve(Path path, ResolveOptions... options) throws IOException {
        EnumSet<ResolveOptions> opt;
        if (options.length == 0) {
            opt = EnumSet.of(ResolveOptions.DEFAULT); // no-op
        } else {
            opt = EnumSet.of(ResolveOptions.DEFAULT, options);
        }

        if (opt.contains(ResolveOptions.BYTES) && opt.contains(ResolveOptions.STRING)) {
            throw new IllegalArgumentException("Incompatible options: BYTES and STRING");
        }
        if (opt.contains(ResolveOptions.BYTES) && opt.contains(ResolveOptions.PATH)) {
            throw new IllegalArgumentException("Incompatible options: BYTES and PATH");
        }
        if (opt.contains(ResolveOptions.BYTES) && opt.contains(ResolveOptions.URI)) {
            throw new IllegalArgumentException("Incompatible options: BYTES and URI");
        }
        if (opt.contains(ResolveOptions.STRING) && opt.contains(ResolveOptions.PATH)) {
            throw new IllegalArgumentException("Incompatible options: STRING and PATH");
        }
        if (opt.contains(ResolveOptions.STRING) && opt.contains(ResolveOptions.URI)) {
            throw new IllegalArgumentException("Incompatible options: STRING and URI");
        }
        if (opt.contains(ResolveOptions.PATH) && opt.contains(ResolveOptions.URI)) {
            throw new IllegalArgumentException("Incompatible options: PATH and URI");
        }

        if (path == null || isMissing(path)) {
            if (!opt.contains(ResolveOptions.REPLACE_NULL)) {
                return null;
            }
            if (opt.contains(ResolveOptions.BYTES)) {
                return new byte[0];
            }
            if (opt.contains(ResolveOptions.PATH)) {
                return path;
            }
            if (opt.contains(ResolveOptions.URI)) {
                return path.toUri();
            }
            // STRING and DEFAULT
            return "";

        }

        if (isList(path)) {
            List<Path> list = getList(path);
            List<Object> objectList = new ArrayList<Object>(list.size());
            for (Path pathElement : list) {
                objectList.add(resolve(pathElement, options));
            }
            return objectList;
        }
        if (opt.contains(ResolveOptions.PATH)) {
            return path;
        }
        if (isError(path)) {
            if (opt.contains(ResolveOptions.REPLACE_ERRORS)) {
                return opt.contains(ResolveOptions.REPLACE_NULL) ? "" : null;
            }
            return getError(path);
        }
        if (opt.contains(ResolveOptions.URI)) {
            if (isReference(path)) {
                return getReference(path);
            } else {
                return path.toUri();
            }
        }
        if (isReference(path)) {
            URI reference = getReference(path);
            String scheme = reference.getScheme();
            if ("file".equals(scheme)) {
                return new File(reference);
            } else {
                try {
                    return reference.toURL();
                } catch (IllegalArgumentException | MalformedURLException e) {
                    return reference;
                }
            }
        }
        if (isValue(path)) {
            if (opt.contains(ResolveOptions.BYTES)) {
                return Files.readAllBytes(path);
            }
            if (opt.contains(ResolveOptions.STRING)) {
                return getStringValue(path);
            }
        }
        // Fall-back - return Path as-is
        return path;
    }

    /**
     * Deeply resolve path as a {@link Stream} that only contain leaf elements of 
     * the specified class.
     * <p>
     * This method is somewhat equivalent to {@link #resolve(Path, ResolveOptions...)}, but 
     * the returned stream is not in any particular order, and will contain the leaf
     * items from all deep lists. Empty lists and error documents are ignored.
     * <p>
     * Any {@link IOException}s occurring during resolution are 
     * wrapped as {@link UncheckedIOException}.
     * <p>
     * Supported types include:
     * <ul>
     *   <li>{@link String}.class</li>
     *   <li><code>byte[].class</code></li>
     *   <li>{@link Path}.class</li>
     *   <li>{@link URI}.class</li>
     *   <li>{@link URL}.class</li>  
     *   <li>{@link File}.class</li>
     *   <li>{@link ErrorDocument}.class</li>
     *   <li>{@link Object}.class</li>
     * </ul>
     * 
     * @param path Data bundle path to resolve
     * @param type Type of objects to return, e.g. <code>String.class</code>
     * @return A {@link Stream} of resolved objects, or an empty stream if no such objects were resolved.
     * @throws UncheckedIOException If the path could not be accessed. 
     */
    public static <T> Stream<T> resolveAsStream(Path path, Class<T> type) throws UncheckedIOException {
        ResolveOptions options;
        if (type == String.class) {
            options = ResolveOptions.STRING;
        } else if (type == byte[].class) {
            options = ResolveOptions.BYTES;
        } else if (type == Path.class) {
            options = ResolveOptions.PATH;
        } else if (type == URI.class) {
            options = ResolveOptions.URI;
        } else {
            // Dummy-option, we'll filter on the returned type anyway
            options = ResolveOptions.DEFAULT;
        }
        if (isList(path)) {
            // return Stream of unordered list of resolved list items,   
            // recursing to find the leaf nodes         
            try {
                return Files.walk(path)
                        // avoid re-recursion
                        .filter(p -> !Files.isDirectory(p)).flatMap(p -> resolveItemAsStream(p, type, options));
            } catch (IOException e) {
                throw new UncheckedIOException(e);
            }
        } else {
            return resolveItemAsStream(path, type, options);
        }
    }

    private static <T> Stream<T> resolveItemAsStream(Path path, Class<T> type, ResolveOptions options)
            throws UncheckedIOException {
        try {
            Object value = resolve(path, options);
            if (type.isInstance(value)) {
                return Stream.of(type.cast(value));
            }
            return Stream.empty();
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }

    public static WorkflowBundleIO getWfBundleIO() {
        if (wfBundleIO == null)
            wfBundleIO = new WorkflowBundleIO();
        return wfBundleIO;
    }

    public static void setWfBundleIO(WorkflowBundleIO wfBundleIO) {
        if (wfBundleIO == null)
            throw new NullPointerException();
        DataBundles.wfBundleIO = wfBundleIO;
    }
}