org.commonwl.view.cwl.CWLService.java Source code

Java tutorial

Introduction

Here is the source code for org.commonwl.view.cwl.CWLService.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.commonwl.view.cwl;

import static org.apache.commons.io.FileUtils.readFileToString;

import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.io.StringWriter;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;

import org.apache.commons.io.FileUtils;
import org.apache.commons.io.FilenameUtils;
import org.apache.jena.iri.IRI;
import org.apache.jena.iri.IRIFactory;
import org.apache.jena.ontology.OntModelSpec;
import org.apache.jena.query.QuerySolution;
import org.apache.jena.query.ResultSet;
import org.apache.jena.rdf.model.Model;
import org.apache.jena.rdf.model.ModelFactory;
import org.apache.jena.riot.RiotException;
import org.commonwl.view.docker.DockerService;
import org.commonwl.view.git.GitDetails;
import org.commonwl.view.graphviz.ModelDotWriter;
import org.commonwl.view.graphviz.RDFDotWriter;
import org.commonwl.view.workflow.Workflow;
import org.commonwl.view.workflow.WorkflowNotFoundException;
import org.commonwl.view.workflow.WorkflowOverview;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.yaml.snakeyaml.Yaml;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.databind.node.TextNode;

/**
 * Provides CWL parsing for workflows to gather an overview
 * for display and visualisation
 */
@Service
public class CWLService {

    private final Logger logger = LoggerFactory.getLogger(this.getClass());
    private final IRIFactory iriFactory = IRIFactory.iriImplementation();

    // Autowired properties/services
    private final RDFService rdfService;
    private final CWLTool cwlTool;
    private final int singleFileSizeLimit;

    // CWL specific strings
    private final String DOC_GRAPH = "$graph";
    private final String CLASS = "class";
    private final String WORKFLOW = "Workflow";
    private final String COMMANDLINETOOL = "CommandLineTool";
    private final String EXPRESSIONTOOL = "ExpressionTool";
    private final String STEPS = "steps";
    private final String INPUTS = "inputs";
    private final String IN = "in";
    private final String OUTPUTS = "outputs";
    private final String OUT = "out";
    private final String ID = "id";
    private final String TYPE = "type";
    private final String LABEL = "label";
    private final String DEFAULT = "default";
    private final String OUTPUT_SOURCE = "outputSource";
    private final String SOURCE = "source";
    private final String DOC = "doc";
    private final String DESCRIPTION = "description";
    private final String ARRAY = "array";
    private final String ARRAY_ITEMS = "items";
    private final String LOCATION = "location";
    private final String RUN = "run";

    /**
     * Constructor for the Common Workflow Language service
     * @param rdfService A service for handling RDF queries
     * @param cwlTool Handles cwltool integration
     * @param singleFileSizeLimit The file size limit for single files
     */
    @Autowired
    public CWLService(RDFService rdfService, CWLTool cwlTool,
            @Value("${singleFileSizeLimit}") int singleFileSizeLimit) {
        this.rdfService = rdfService;
        this.cwlTool = cwlTool;
        this.singleFileSizeLimit = singleFileSizeLimit;
    }

    /**
     * Gets whether a file is packed using schema salad
     * @param workflowFile The file to be parsed
     * @return Whether the file is packed
     */
    public boolean isPacked(File workflowFile) throws IOException {
        if (workflowFile.length() > singleFileSizeLimit) {
            return false;
        }
        String fileContent = readFileToString(workflowFile);
        return fileContent.contains("$graph");
    }

    /**
     * Gets a list of workflows from a packed CWL file
     * @param packedFile The packed CWL file
     * @return The list of workflow overviews
     */
    public List<WorkflowOverview> getWorkflowOverviewsFromPacked(File packedFile) throws IOException {
        if (packedFile.length() <= singleFileSizeLimit) {
            List<WorkflowOverview> overviews = new ArrayList<>();

            JsonNode packedJson = yamlStringToJson(readFileToString(packedFile));

            if (packedJson.has(DOC_GRAPH)) {
                for (JsonNode jsonNode : packedJson.get(DOC_GRAPH)) {
                    if (extractProcess(jsonNode) == CWLProcess.WORKFLOW) {
                        WorkflowOverview overview = new WorkflowOverview(jsonNode.get(ID).asText(),
                                extractLabel(jsonNode), extractDoc(jsonNode));
                        overviews.add(overview);
                    }
                }
            } else {
                throw new IOException("The file given was not recognised as a packed CWL file");
            }

            return overviews;

        } else {
            throw new IOException("File '" + packedFile.getName() + "' is over singleFileSizeLimit - "
                    + FileUtils.byteCountToDisplaySize(packedFile.length()) + "/"
                    + FileUtils.byteCountToDisplaySize(singleFileSizeLimit));
        }
    }

    /**
     * Gets the Workflow object from internal parsing
     * @param workflowFile The workflow file to be parsed
     * @param packedWorkflowId The ID of the workflow object if the file is packed
     * @return The constructed workflow object
     */
    public Workflow parseWorkflowNative(Path workflowFile, String packedWorkflowId) throws IOException {

        // Check file size limit before parsing
        long fileSizeBytes = Files.size(workflowFile);
        if (fileSizeBytes <= singleFileSizeLimit) {

            // Parse file as yaml
            JsonNode cwlFile = yamlStringToJson(readFileToString(workflowFile.toFile()));

            // Check packed workflow occurs
            if (packedWorkflowId != null) {
                boolean found = false;
                if (cwlFile.has(DOC_GRAPH)) {
                    for (JsonNode jsonNode : cwlFile.get(DOC_GRAPH)) {
                        if (extractProcess(jsonNode) == CWLProcess.WORKFLOW) {
                            String currentId = jsonNode.get(ID).asText();
                            if (currentId.startsWith("#")) {
                                currentId = currentId.substring(1);
                            }
                            if (currentId.equals(packedWorkflowId)) {
                                cwlFile = jsonNode;
                                found = true;
                                break;
                            }
                        }
                    }
                }
                if (!found)
                    throw new WorkflowNotFoundException();
            } else {
                // Check the current json node is a workflow
                if (extractProcess(cwlFile) != CWLProcess.WORKFLOW) {
                    throw new WorkflowNotFoundException();
                }
            }

            // Use filename for label if there is no defined one
            String label = extractLabel(cwlFile);
            if (label == null) {
                label = workflowFile.getFileName().toString();
            }

            // Construct the rest of the workflow model
            Workflow workflowModel = new Workflow(label, extractDoc(cwlFile), getInputs(cwlFile),
                    getOutputs(cwlFile), getSteps(cwlFile));

            workflowModel.setCwltoolVersion(cwlTool.getVersion());

            // Generate DOT graph
            StringWriter graphWriter = new StringWriter();
            ModelDotWriter dotWriter = new ModelDotWriter(graphWriter);
            try {
                dotWriter.writeGraph(workflowModel);
                workflowModel.setVisualisationDot(graphWriter.toString());
            } catch (IOException ex) {
                logger.error("Failed to create DOT graph for workflow: " + ex.getMessage());
            }

            return workflowModel;

        } else {
            throw new IOException("File '" + workflowFile.getFileName() + "' is over singleFileSizeLimit - "
                    + FileUtils.byteCountToDisplaySize(fileSizeBytes) + "/"
                    + FileUtils.byteCountToDisplaySize(singleFileSizeLimit));
        }

    }

    /**
     * Create a workflow model using cwltool rdf output
     * @param basicModel The basic workflow object created thus far
     * @param workflowFile The workflow file to run cwltool on
     * @return The constructed workflow object
     */
    public Workflow parseWorkflowWithCwltool(Workflow basicModel, Path workflowFile, Path workTree)
            throws CWLValidationException {
        GitDetails gitDetails = basicModel.getRetrievedFrom();
        String latestCommit = basicModel.getLastCommit();
        String packedWorkflowID = gitDetails.getPackedId();

        // Get paths to workflow
        String url = basicModel.getIdentifier();
        String workflowFileURI = workflowFile.toAbsolutePath().toUri().toString();
        URI workTreeUri = workTree.toAbsolutePath().toUri();
        String localPath = workflowFileURI;
        String gitPath = gitDetails.getPath();
        if (packedWorkflowID != null) {
            if (packedWorkflowID.charAt(0) != '#') {
                localPath += "#";
                gitPath += "#";
            }
            localPath += packedWorkflowID;
            gitPath += packedWorkflowID;
        }

        // Get RDF representation from cwltool
        if (!rdfService.graphExists(url)) {
            String rdf = cwlTool.getRDF(localPath);
            // Replace /tmp/123123 with permalink base 
            // NOTE: We do not just replace workflowFileURI, all referenced files will also get rewritten
            rdf = rdf.replace(workTreeUri.toString(), "https://w3id.org/cwl/view/git/" + latestCommit + "/");
            // Workaround for common-workflow-language/cwltool#427
            rdf = rdf.replace("<rdfs:>", "<http://www.w3.org/2000/01/rdf-schema#>");

            // Create a workflow model from RDF representation
            Model model = ModelFactory.createDefaultModel();
            model.read(new ByteArrayInputStream(rdf.getBytes()), null, "TURTLE");

            // Store the model
            rdfService.storeModel(url, model);
        }

        // Base workflow details
        String label = FilenameUtils.getName(url);
        String doc = null;
        ResultSet labelAndDoc = rdfService.getLabelAndDoc(url);
        if (labelAndDoc.hasNext()) {
            QuerySolution labelAndDocSoln = labelAndDoc.nextSolution();
            if (labelAndDocSoln.contains("label")) {
                label = labelAndDocSoln.get("label").toString();
            }
            if (labelAndDocSoln.contains("doc")) {
                doc = labelAndDocSoln.get("doc").toString();
            }
        }

        // Inputs
        Map<String, CWLElement> wfInputs = new HashMap<>();
        ResultSet inputs = rdfService.getInputs(url);
        while (inputs.hasNext()) {
            QuerySolution input = inputs.nextSolution();
            String inputName = rdfService.stepNameFromURI(gitPath, input.get("name").toString());

            CWLElement wfInput = new CWLElement();
            if (input.contains("type")) {
                String type;
                if (input.get("type").toString().equals("https://w3id.org/cwl/salad#array")) {
                    type = typeURIToString(input.get("items").toString()) + "[]";
                } else {
                    type = typeURIToString(input.get("type").toString());
                }
                if (input.contains("null")) {
                    type += " (Optional)";
                }
                wfInput.setType(type);
            }
            if (input.contains("format")) {
                String format = input.get("format").toString();
                setFormat(wfInput, format);
            }
            if (input.contains("label")) {
                wfInput.setLabel(input.get("label").toString());
            }
            if (input.contains("doc")) {
                wfInput.setDoc(input.get("doc").toString());
            }
            wfInputs.put(rdfService.labelFromName(inputName), wfInput);
        }

        // Outputs
        Map<String, CWLElement> wfOutputs = new HashMap<>();
        ResultSet outputs = rdfService.getOutputs(url);
        while (outputs.hasNext()) {
            QuerySolution output = outputs.nextSolution();
            CWLElement wfOutput = new CWLElement();

            String outputName = rdfService.stepNameFromURI(gitPath, output.get("name").toString());
            if (output.contains("type")) {
                String type;
                if (output.get("type").toString().equals("https://w3id.org/cwl/salad#array")) {
                    type = typeURIToString(output.get("items").toString()) + "[]";
                } else {
                    type = typeURIToString(output.get("type").toString());
                }
                if (output.contains("null")) {
                    type += " (Optional)";
                }
                wfOutput.setType(type);
            }

            if (output.contains("src")) {
                wfOutput.addSourceID(rdfService.stepNameFromURI(gitPath, output.get("src").toString()));
            }
            if (output.contains("format")) {
                String format = output.get("format").toString();
                setFormat(wfOutput, format);
            }
            if (output.contains("label")) {
                wfOutput.setLabel(output.get("label").toString());
            }
            if (output.contains("doc")) {
                wfOutput.setDoc(output.get("doc").toString());
            }
            wfOutputs.put(rdfService.labelFromName(outputName), wfOutput);
        }

        // Steps
        Map<String, CWLStep> wfSteps = new HashMap<>();
        ResultSet steps = rdfService.getSteps(url);
        while (steps.hasNext()) {
            QuerySolution step = steps.nextSolution();
            String uri = rdfService.stepNameFromURI(gitPath, step.get("step").toString());
            if (wfSteps.containsKey(uri)) {
                // Already got step details, add extra source ID
                if (step.contains("src")) {
                    CWLElement src = new CWLElement();
                    src.addSourceID(rdfService.stepNameFromURI(gitPath, step.get("src").toString()));
                    wfSteps.get(uri).getSources().put(step.get("stepinput").toString(), src);
                } else if (step.contains("default")) {
                    CWLElement src = new CWLElement();
                    src.setDefaultVal(rdfService.formatDefault(step.get("default").toString()));
                    wfSteps.get(uri).getSources().put(step.get("stepinput").toString(), src);
                }
            } else {
                // Add new step
                CWLStep wfStep = new CWLStep();

                IRI workflowPath = iriFactory.construct(url).resolve("./");
                IRI runPath = iriFactory.construct(step.get("run").asResource().getURI());
                wfStep.setRun(workflowPath.relativize(runPath).toString());
                wfStep.setRunType(rdfService.strToRuntype(step.get("runtype").toString()));

                if (step.contains("src")) {
                    CWLElement src = new CWLElement();
                    src.addSourceID(rdfService.stepNameFromURI(gitPath, step.get("src").toString()));
                    Map<String, CWLElement> srcList = new HashMap<>();
                    srcList.put(rdfService.stepNameFromURI(gitPath, step.get("stepinput").toString()), src);
                    wfStep.setSources(srcList);
                } else if (step.contains("default")) {
                    CWLElement src = new CWLElement();
                    src.setDefaultVal(rdfService.formatDefault(step.get("default").toString()));
                    Map<String, CWLElement> srcList = new HashMap<>();
                    srcList.put(rdfService.stepNameFromURI(gitPath, step.get("stepinput").toString()), src);
                    wfStep.setSources(srcList);
                }
                if (step.contains("label")) {
                    wfStep.setLabel(step.get("label").toString());
                }
                if (step.contains("doc")) {
                    wfStep.setDoc(step.get("doc").toString());
                }
                wfSteps.put(rdfService.labelFromName(uri), wfStep);
            }
        }
        // Try to determine license
        ResultSet licenseResult = rdfService.getLicense(url);
        String licenseLink = null;
        if (licenseResult.hasNext()) {
            licenseLink = licenseResult.next().get("license").toString();
        } else {
            // Check for "LICENSE"-like files in root of git repo
            for (String licenseCandidate : new String[] { "LICENSE", "LICENSE.txt", "LICENSE.md" }) {
                // FIXME: This might wrongly match lower-case "license.txt" in case-insensitive file systems
                // but the URL would not work
                if (Files.isRegularFile(workTree.resolve(licenseCandidate))) {
                    // Link to it by raw URL
                    licenseLink = basicModel.getRetrievedFrom().getRawUrl(null, licenseCandidate);
                }
            }
        }

        // Docker link
        ResultSet dockerResult = rdfService.getDockerLink(url);
        String dockerLink = null;
        if (dockerResult.hasNext()) {
            QuerySolution docker = dockerResult.nextSolution();
            if (docker.contains("pull")) {
                dockerLink = DockerService.getDockerHubURL(docker.get("pull").toString());
            } else {
                dockerLink = "true";
            }
        }

        // Create workflow model
        Workflow workflowModel = new Workflow(label, doc, wfInputs, wfOutputs, wfSteps, dockerLink, licenseLink);

        // Generate DOT graph
        StringWriter graphWriter = new StringWriter();
        RDFDotWriter RDFDotWriter = new RDFDotWriter(graphWriter, rdfService, gitPath);
        try {
            RDFDotWriter.writeGraph(url);
            workflowModel.setVisualisationDot(graphWriter.toString());
        } catch (IOException ex) {
            logger.error("Failed to create DOT graph for workflow: " + ex.getMessage());
        }

        return workflowModel;

    }

    /**
     * Get an overview of a workflow
     * @param file A file, potentially a workflow
     * @return A constructed WorkflowOverview of the workflow
     * @throws IOException Any API errors which may have occurred
     */
    public WorkflowOverview getWorkflowOverview(File file) throws IOException {

        // Get the content of this file from Github
        long fileSizeBytes = file.length();

        // Check file size limit before parsing
        if (fileSizeBytes <= singleFileSizeLimit) {

            // Parse file as yaml
            JsonNode cwlFile = yamlStringToJson(readFileToString(file));

            // If the CWL file is packed there can be multiple workflows in a file
            int packedCount = 0;
            if (cwlFile.has(DOC_GRAPH)) {
                // Packed CWL, find the first subelement which is a workflow and take it
                for (JsonNode jsonNode : cwlFile.get(DOC_GRAPH)) {
                    if (extractProcess(jsonNode) == CWLProcess.WORKFLOW) {
                        cwlFile = jsonNode;
                        packedCount++;
                    }
                }
                if (packedCount > 1) {
                    return new WorkflowOverview("/" + file.getName(), "Packed file",
                            "contains " + packedCount + " workflows");
                }
            }

            // Can only make an overview if this is a workflow
            if (extractProcess(cwlFile) == CWLProcess.WORKFLOW) {
                // Use filename for label if there is no defined one
                String label = extractLabel(cwlFile);
                if (label == null) {
                    label = file.getName();
                }

                // Return the constructed overview
                return new WorkflowOverview("/" + file.getName(), label, extractDoc(cwlFile));
            } else {
                // Return null if not a workflow file
                return null;
            }
        } else {
            throw new IOException("File '" + file.getName() + "' is over singleFileSizeLimit - "
                    + FileUtils.byteCountToDisplaySize(fileSizeBytes) + "/"
                    + FileUtils.byteCountToDisplaySize(singleFileSizeLimit));
        }

    }

    /**
     * Set the format for an input or output, handling ontologies
     * @param inputOutput The input or output CWL Element
     * @param format The format URI
     */
    private void setFormat(CWLElement inputOutput, String format) {
        inputOutput.setFormat(format);
        try {
            if (!rdfService.ontPropertyExists(format)) {
                Model ontModel = ModelFactory.createOntologyModel(OntModelSpec.OWL_MEM);
                ontModel.read(format, null, "RDF/XML");
                rdfService.addToOntologies(ontModel);
            }
            String formatLabel = rdfService.getOntLabel(format);
            inputOutput.setType(inputOutput.getType() + " [" + formatLabel + "]");
        } catch (RiotException ex) {
            inputOutput.setType(inputOutput.getType() + " [format]");
        }
    }

    /**
     * Convert RDF URI for a type to a name
     * @param uri The URI for the type
     * @return The human readable name for that type
     */
    private String typeURIToString(String uri) {
        switch (uri) {
        case "http://www.w3.org/2001/XMLSchema#string":
            return "String";
        case "https://w3id.org/cwl/cwl#File":
            return "File";
        case "http://www.w3.org/2001/XMLSchema#boolean":
            return "Boolean";
        case "http://www.w3.org/2001/XMLSchema#int":
            return "Integer";
        case "http://www.w3.org/2001/XMLSchema#double":
            return "Double";
        case "http://www.w3.org/2001/XMLSchema#float":
            return "Float";
        case "http://www.w3.org/2001/XMLSchema#long":
            return "Long";
        case "https://w3id.org/cwl/cwl#Directory":
            return "Directory";
        default:
            return uri;
        }
    }

    /**
     * Converts a yaml String to JsonNode
     * @param yaml A String containing the yaml content
     * @return A JsonNode with the content of the document
     */
    private JsonNode yamlStringToJson(String yaml) {
        Yaml reader = new Yaml();
        ObjectMapper mapper = new ObjectMapper();
        return mapper.valueToTree(reader.load(yaml));
    }

    /**
     * Extract the label from a node
     * @param node The node to have the label extracted from
     * @return The string for the label of the node
     */
    private String extractLabel(JsonNode node) {
        if (node != null && node.has(LABEL)) {
            return node.get(LABEL).asText();
        }
        return null;
    }

    /**
     * Extract the class parameter from a node representing a document
     * @param rootNode The root node of a cwl document
     * @return Which process this document represents
     */
    private CWLProcess extractProcess(JsonNode rootNode) {
        if (rootNode != null) {
            if (rootNode.has(CLASS)) {
                switch (rootNode.get(CLASS).asText()) {
                case WORKFLOW:
                    return CWLProcess.WORKFLOW;
                case COMMANDLINETOOL:
                    return CWLProcess.COMMANDLINETOOL;
                case EXPRESSIONTOOL:
                    return CWLProcess.EXPRESSIONTOOL;
                }
            }
        }
        return null;
    }

    /**
     * Get the steps for a particular document
     * @param cwlDoc The document to get steps for
     * @return A map of step IDs and details related to them
     */
    private Map<String, CWLStep> getSteps(JsonNode cwlDoc) {
        if (cwlDoc != null && cwlDoc.has(STEPS)) {
            Map<String, CWLStep> returnMap = new HashMap<>();

            JsonNode steps = cwlDoc.get(STEPS);
            if (steps.getClass() == ArrayNode.class) {
                // Explicit ID and other fields within each input list
                for (JsonNode step : steps) {
                    CWLStep stepObject = new CWLStep(extractLabel(step), extractDoc(step), extractRun(step),
                            getInputs(step));
                    returnMap.put(extractID(step), stepObject);
                }
            } else if (steps.getClass() == ObjectNode.class) {
                // ID is the key of each object
                Iterator<Map.Entry<String, JsonNode>> iterator = steps.fields();
                while (iterator.hasNext()) {
                    Map.Entry<String, JsonNode> stepNode = iterator.next();
                    JsonNode stepJson = stepNode.getValue();
                    CWLStep stepObject = new CWLStep(extractLabel(stepJson), extractDoc(stepJson),
                            extractRun(stepJson), getInputs(stepJson));
                    returnMap.put(stepNode.getKey(), stepObject);
                }
            }

            return returnMap;
        }
        return null;
    }

    /**
     * Get a the inputs for a particular document
     * @param cwlDoc The document to get inputs for
     * @return A map of input IDs and details related to them
     */
    private Map<String, CWLElement> getInputs(JsonNode cwlDoc) {
        if (cwlDoc != null) {
            if (cwlDoc.has(INPUTS)) {
                // For all version workflow inputs/outputs and draft steps
                return getInputsOutputs(cwlDoc.get(INPUTS));
            } else if (cwlDoc.has(IN)) {
                // For V1.0 steps
                return getStepInputsOutputs(cwlDoc.get(IN));
            }
        }
        return null;
    }

    /**
     * Get the outputs for a particular document
     * @param cwlDoc The document to get outputs for
     * @return A map of output IDs and details related to them
     */
    private Map<String, CWLElement> getOutputs(JsonNode cwlDoc) {
        if (cwlDoc != null) {
            // For all version workflow inputs/outputs and draft steps
            if (cwlDoc.has(OUTPUTS)) {
                return getInputsOutputs(cwlDoc.get(OUTPUTS));
            }
            // Outputs are not gathered for v1 steps
        }
        return null;
    }

    /**
     * Get inputs or outputs from an in or out node
     * @param inOut The in or out node
     * @return A map of input IDs and details related to them
     */
    private Map<String, CWLElement> getStepInputsOutputs(JsonNode inOut) {
        Map<String, CWLElement> returnMap = new HashMap<>();

        if (inOut.getClass() == ArrayNode.class) {
            // array<WorkflowStepInput>
            for (JsonNode inOutNode : inOut) {
                if (inOutNode.getClass() == ObjectNode.class) {
                    CWLElement inputOutput = new CWLElement();
                    List<String> sources = extractSource(inOutNode);
                    if (sources.size() > 0) {
                        for (String source : sources) {
                            inputOutput.addSourceID(stepIDFromSource(source));
                        }
                    } else {
                        inputOutput.setDefaultVal(extractDefault(inOutNode));
                    }
                    returnMap.put(extractID(inOutNode), inputOutput);
                }
            }
        } else if (inOut.getClass() == ObjectNode.class) {
            // map<WorkflowStepInput.id, WorkflowStepInput.source>
            Iterator<Map.Entry<String, JsonNode>> iterator = inOut.fields();
            while (iterator.hasNext()) {
                Map.Entry<String, JsonNode> inOutNode = iterator.next();
                CWLElement inputOutput = new CWLElement();
                if (inOutNode.getValue().getClass() == ObjectNode.class) {
                    JsonNode properties = inOutNode.getValue();
                    if (properties.has(SOURCE)) {
                        inputOutput.addSourceID(stepIDFromSource(properties.get(SOURCE).asText()));
                    } else {
                        inputOutput.setDefaultVal(extractDefault(properties));
                    }
                } else if (inOutNode.getValue().getClass() == ArrayNode.class) {
                    for (JsonNode key : inOutNode.getValue()) {
                        inputOutput.addSourceID(stepIDFromSource(key.asText()));
                    }
                } else {
                    inputOutput.addSourceID(stepIDFromSource(inOutNode.getValue().asText()));
                }
                returnMap.put(inOutNode.getKey(), inputOutput);
            }
        }

        return returnMap;
    }

    /**
     * Get inputs or outputs from an inputs or outputs node
     * @param inputsOutputs The inputs or outputs node
     * @return A map of input IDs and details related to them
     */
    private Map<String, CWLElement> getInputsOutputs(JsonNode inputsOutputs) {
        Map<String, CWLElement> returnMap = new HashMap<>();

        if (inputsOutputs.getClass() == ArrayNode.class) {
            // Explicit ID and other fields within each list
            for (JsonNode inputOutput : inputsOutputs) {
                String id = inputOutput.get(ID).asText();
                if (id.charAt(0) == '#') {
                    id = id.substring(1);
                }
                returnMap.put(id, getDetails(inputOutput));
            }
        } else if (inputsOutputs.getClass() == ObjectNode.class) {
            // ID is the key of each object
            Iterator<Map.Entry<String, JsonNode>> iterator = inputsOutputs.fields();
            while (iterator.hasNext()) {
                Map.Entry<String, JsonNode> inputOutputNode = iterator.next();
                returnMap.put(inputOutputNode.getKey(), getDetails(inputOutputNode.getValue()));
            }
        }

        return returnMap;
    }

    /**
     * Gets the details of an input or output
     * @param inputOutput The node of the particular input or output
     * @return An CWLElement object with the label, doc and type extracted
     */
    private CWLElement getDetails(JsonNode inputOutput) {
        if (inputOutput != null) {
            CWLElement details = new CWLElement();

            // Shorthand notation "id: type" - no label/doc/other params
            if (inputOutput.getClass() == TextNode.class) {
                details.setType(inputOutput.asText());
            } else {
                details.setLabel(extractLabel(inputOutput));
                details.setDoc(extractDoc(inputOutput));
                extractSource(inputOutput).forEach(details::addSourceID);
                details.setDefaultVal(extractDefault(inputOutput));

                // Type is only for inputs
                if (inputOutput.has(TYPE)) {
                    details.setType(extractTypes(inputOutput.get(TYPE)));
                }
            }

            return details;
        }
        return null;
    }

    /**
     * Extract the id from a node
     * @param node The node to have the id extracted from
     * @return The string for the id of the node
     */
    private String extractID(JsonNode node) {
        if (node != null && node.has(ID)) {
            String id = node.get(ID).asText();
            if (id.startsWith("#")) {
                return id.substring(1);
            }
            return id;
        }
        return null;
    }

    /**
     * Extract the default value from a node
     * @param node The node to have the label extracted from
     * @return The string for the default value of the node
     */
    private String extractDefault(JsonNode node) {
        if (node != null && node.has(DEFAULT)) {
            if (node.get(DEFAULT).has(LOCATION)) {
                return node.get(DEFAULT).get(LOCATION).asText();
            } else {
                return "\\\"" + node.get(DEFAULT).asText() + "\\\"";
            }
        }
        return null;
    }

    /**
     * Extract the source or outputSource from a node
     * @param node The node to have the sources extracted from
     * @return A list of strings for the sources
     */
    private List<String> extractSource(JsonNode node) {
        if (node != null) {
            List<String> sources = new ArrayList<String>();
            JsonNode sourceNode = null;

            // outputSource and source treated the same
            if (node.has(OUTPUT_SOURCE)) {
                sourceNode = node.get(OUTPUT_SOURCE);
            } else if (node.has(SOURCE)) {
                sourceNode = node.get(SOURCE);
            }

            if (sourceNode != null) {
                // Single source
                if (sourceNode.getClass() == TextNode.class) {
                    sources.add(stepIDFromSource(sourceNode.asText()));
                }
                // Can be an array of multiple sources
                if (sourceNode.getClass() == ArrayNode.class) {
                    for (JsonNode source : sourceNode) {
                        sources.add(stepIDFromSource(source.asText()));
                    }
                }
            }

            return sources;
        }
        return null;
    }

    /**
     * Gets just the step ID from source of format 'stepID</ or .>outputID'
     * @param source The source
     * @return The step ID
     */
    private String stepIDFromSource(String source) {
        if (source != null && source.length() > 0) {
            // Strip leading # if it exists
            if (source.charAt(0) == '#') {
                source = source.substring(1);
            }

            // Draft 3/V1 notation is 'stepID/outputID'
            int slashSplit = source.indexOf("/");
            if (slashSplit != -1) {
                source = source.substring(0, slashSplit);
            } else {
                // Draft 2 notation was 'stepID.outputID'
                int dotSplit = source.indexOf(".");
                if (dotSplit != -1) {
                    source = source.substring(0, dotSplit);
                }
            }
        }
        return source;
    }

    /**
     * Extract the doc or description from a node
     * @param node The node to have the doc/description extracted from
     * @return The string for the doc/description of the node
     */
    private String extractDoc(JsonNode node) {
        if (node != null) {
            if (node.has(DOC)) {
                return node.get(DOC).asText();
            } else if (node.has(DESCRIPTION)) {
                // This is to support older standards of cwl which use description instead of doc
                return node.get(DESCRIPTION).asText();
            }
        }
        return null;
    }

    /**
     * Extract the types from a node representing inputs or outputs
     * @param typeNode The root node representing an input or output
     * @return A string with the types listed
     */
    private String extractTypes(JsonNode typeNode) {
        if (typeNode != null) {
            if (typeNode.getClass() == TextNode.class) {
                // Single type
                return typeNode.asText();
            } else if (typeNode.getClass() == ArrayNode.class) {
                // Multiple types, build a string to represent them
                StringBuilder typeDetails = new StringBuilder();
                boolean optional = false;
                for (JsonNode type : typeNode) {
                    if (type.getClass() == TextNode.class) {
                        // This is a simple type
                        if (type.asText().equals("null")) {
                            // null as a type means this field is optional
                            optional = true;
                        } else {
                            // Add a simple type to the string
                            typeDetails.append(type.asText());
                            typeDetails.append(", ");
                        }
                    } else if (typeNode.getClass() == ArrayNode.class) {
                        // This is a verbose type with sub-fields broken down into type: and other params
                        if (type.get(TYPE).asText().equals(ARRAY)) {
                            typeDetails.append(type.get(ARRAY_ITEMS).asText());
                            typeDetails.append("[], ");
                        } else {
                            typeDetails.append(type.get(TYPE).asText());
                        }
                    }
                }

                // Trim off excessive separators
                if (typeDetails.length() > 1) {
                    typeDetails.setLength(typeDetails.length() - 2);
                }

                // Add optional if null was included in the multiple types
                if (optional)
                    typeDetails.append("?");

                // Set the type to the constructed string
                return typeDetails.toString();

            } else if (typeNode.getClass() == ObjectNode.class) {
                // Type: array and items:
                if (typeNode.has(ARRAY_ITEMS)) {
                    return typeNode.get(ARRAY_ITEMS).asText() + "[]";
                }
            }
        }
        return null;
    }

    /**
     * Extract the run parameter from a node representing a step
     * @param stepNode The root node of a step
     * @return A string with the run parameter if it exists
     */
    private String extractRun(JsonNode stepNode) {
        if (stepNode != null) {
            if (stepNode.has(RUN)) {
                return stepNode.get(RUN).asText();
            }
        }
        return null;
    }

}