Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
d64d1e2
fix: replace Lombok annotations with explicit Java code
aaronstrachardt May 12, 2026
66bbf89
feat: add snakeyaml dependency for CWL v1.2 parsing
aaronstrachardt May 12, 2026
dfb65e4
feat: add DTOs for CWL parse response
aaronstrachardt May 12, 2026
ec0e951
feat: add EDAMTaxonomyService for async EDAM OWL label resolution
aaronstrachardt May 12, 2026
247f216
feat: add CwlParser to extract DAG from CWL v1.2 workflows
aaronstrachardt May 12, 2026
6eae0df
feat: add /alternatives/parse REST endpoint
aaronstrachardt May 12, 2026
dbd5a6f
revert: restore Lombok annotations (target Java 17)
aaronstrachardt May 14, 2026
0e262a0
refactor: replace OWL-based taxonomy loading with static JSON lookup
aaronstrachardt May 14, 2026
1223325
refactor: load EDAM OWL lazily on demand instead of static JSON
aaronstrachardt May 14, 2026
93f9c6b
refactor: remove /alternatives/warm endpoint
aaronstrachardt May 14, 2026
f933834
test: add T-PAR-01, T-TAX-01, T-ROB-01 for CwlParser and EdamLabels
aaronstrachardt May 14, 2026
56e577c
test: add @SpringBootTest to CwlParserTest and EdamLabelsTest for sty…
aaronstrachardt May 14, 2026
bd28460
test: add AlternativesControllerTest integration tests for POST /alte…
aaronstrachardt May 14, 2026
90a85aa
test: rename test methods to camelCase, fix two failing tests in Alte…
aaronstrachardt May 14, 2026
4694bc5
refactor: load EDAM labels from static JSON instead of live OWL download
aaronstrachardt Jun 15, 2026
53640f4
refactor: remove unnecessary references
aaronstrachardt Jun 24, 2026
b0ce21a
refactor: replace ApeTaxTuple.java with the existing TaxonomyElem.jav…
aaronstrachardt Jun 29, 2026
1ad50ec
refactor: Use @Getter/@AllArgsConstructor on GraphEdge.java, GraphNod…
aaronstrachardt Jun 29, 2026
7b883d0
Merge pull request #101 from aaronstrachardt/generate-alternatives
eladrion Jul 3, 2026
dfebf51
chore: add Aaron Strachardt to authors in citation.cff
aaronstrachardt Jul 3, 2026
6f7bd2b
Merge pull request #103 from aaronstrachardt/patch-1
eladrion Jul 3, 2026
1bb0183
Merge branch 'main' into feat_workflow_alternative_generation
eladrion Sep 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ authors:
email: mario.frank@uni-potsdam.de
affiliation: University of Potsdam, Germany
orcid: 'https://orcid.org/0000-0001-8888-7475'
- given-names: Aaron
family-names: Strachardt
email: aaron.strachardt@desy.de
affiliation: University of Potsdam, Germany
orcid: 'https://orcid.org/0009-0004-9542-1154'
identifiers:
- type: doi
value: 10.5281/zenodo.10048235
Expand Down
6 changes: 6 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,12 @@
<version>3.1.0</version>
</dependency>

<!-- snakeyaml: CWL-Parsing für POST /alternatives/parse -->
<dependency>
<groupId>org.yaml</groupId>
<artifactId>snakeyaml</artifactId>
</dependency>

</dependencies>

<build>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package nl.esciencecenter.controller;

import java.io.IOException;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import nl.esciencecenter.controller.dto.ParseResponse;
import nl.esciencecenter.restape.CwlParser;
import nl.esciencecenter.restape.EdamLabels;

@RestController
@RequestMapping("/alternatives")
public class AlternativesController {

@Autowired
private EdamLabels edamLabels;

/**
* Parses a CWL v1.2 workflow and returns its DAG representation plus
* workflow-level I/O terms for use as APE synthesis constraints.
*/
@PostMapping(value = "/parse", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Parse a CWL workflow file",
description = "Accepts a CWL v1.2 Workflow file as multipart/form-data and returns a " +
"graph-optimised representation: tool nodes, data-flow edges, and the " +
"workflow-level input/output EDAM terms.",
tags = {"Alternatives"},
responses = {
@ApiResponse(responseCode = "200",
description = "Successful operation. Graph representation of the CWL workflow is returned.",
content = @Content(
schema = @Schema(implementation = ParseResponse.class),
mediaType = MediaType.APPLICATION_JSON_VALUE)),
@ApiResponse(responseCode = "400", description = "Invalid or unsupported CWL file")
})
public ResponseEntity<ParseResponse> parseCwl(
@RequestParam("cwl_file") MultipartFile cwlFile) throws IOException {
edamLabels.ensureLoaded();
ParseResponse response = CwlParser.parse(cwlFile.getInputStream(), edamLabels::resolve);
return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(response);
}

@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<String> handleIllegalArgument(IllegalArgumentException e) {
return ResponseEntity.badRequest().body(e.getMessage());
}

@ExceptionHandler(IOException.class)
public ResponseEntity<String> handleIOException(IOException e) {
return ResponseEntity.badRequest().body(e.getMessage());
}

@ExceptionHandler(Exception.class)
public ResponseEntity<String> handleAny(Exception e) {
return ResponseEntity.internalServerError()
.body(e.getClass().getSimpleName() + ": " + e.getMessage());
}
}
11 changes: 11 additions & 0 deletions src/main/java/nl/esciencecenter/controller/dto/GraphEdge.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package nl.esciencecenter.controller.dto;

import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public class GraphEdge {
private final String source;
private final String target;
}
21 changes: 21 additions & 0 deletions src/main/java/nl/esciencecenter/controller/dto/GraphNode.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package nl.esciencecenter.controller.dto;

import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public class GraphNode {
private final String id;
private final String label;
private final NodeType type;

/**
* Kind of node in the workflow graph. Constants are lowercase so Jackson
* serialises them as {@code "input"}/{@code "tool"}/{@code "output"} for the
* frontend (matching the existing {@code ImageFormat} enum convention).
*/
public enum NodeType {
input, tool, output
}
}
15 changes: 15 additions & 0 deletions src/main/java/nl/esciencecenter/controller/dto/ParseResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package nl.esciencecenter.controller.dto;

import java.util.List;

import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public class ParseResponse {
private final List<GraphNode> nodes;
private final List<GraphEdge> edges;
private final List<TaxonomyElem> inputs;
private final List<TaxonomyElem> outputs;
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
package nl.esciencecenter.controller.dto;

import com.fasterxml.jackson.annotation.JsonInclude;

import lombok.AllArgsConstructor;
import lombok.NoArgsConstructor;

/**
* This class represents a single element of the taxonomy.
* TODO: This class is not used at the moment, but it is a good idea to use it
* in the future.
*
* Used for taxonomy tree responses as well as for the flat input/output EDAM
* terms of a parsed workflow, where only {@code id} and {@code label} are set.
*/
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class TaxonomyElem {
public String id;
public String label;
Expand Down
178 changes: 178 additions & 0 deletions src/main/java/nl/esciencecenter/restape/CwlParser.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
package nl.esciencecenter.restape;

import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.UnaryOperator;

import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;

import nl.esciencecenter.controller.dto.GraphEdge;
import nl.esciencecenter.controller.dto.GraphNode;
import nl.esciencecenter.controller.dto.ParseResponse;
import nl.esciencecenter.controller.dto.TaxonomyElem;

/**
* Transforms a CWL v1.2 Workflow document into the graph-optimised ParseResponse.
*/
public class CwlParser {

private CwlParser() {}

/**
* Parses a CWL v1.2 Workflow from the given stream and returns the full DAG
* including input and output data nodes.
*
* @throws IllegalArgumentException if the document fails validation or is structurally incomplete
* @throws IOException if the stream cannot be read
*/
@SuppressWarnings("unchecked")
public static ParseResponse parse(InputStream inputStream, UnaryOperator<String> labelResolver) throws IOException {
Yaml yaml = new Yaml(new SafeConstructor(new LoaderOptions()));
Map<String, Object> cwl;
try {
cwl = yaml.load(inputStream);
} catch (Exception e) {
throw new IllegalArgumentException("CWL file could not be parsed as YAML: " + e.getMessage());
}
if (cwl == null) {
throw new IllegalArgumentException("CWL file is empty.");
}

// Step 1 – Validate
validateMetadata(cwl);

Map<String, Object> inputsSection = (Map<String, Object>) cwl.get("inputs");
Map<String, Object> stepsSection = (Map<String, Object>) cwl.get("steps");
Map<String, Object> outputsSection = (Map<String, Object>) cwl.get("outputs");

if (stepsSection == null || stepsSection.isEmpty()) {
throw new IllegalArgumentException("CWL Workflow must contain a non-empty 'steps' section.");
}

List<GraphNode> nodes = new ArrayList<>();
List<GraphEdge> edges = new ArrayList<>();
Set<String> seen = new LinkedHashSet<>();

// Step 2a – Input nodes (dagre places them at the top in TB layout)
Set<String> inputIds = new LinkedHashSet<>();
if (inputsSection != null) {
for (Map.Entry<String, Object> entry : inputsSection.entrySet()) {
String id = entry.getKey();
String label = formatLabel(entry.getValue(), id, labelResolver);
nodes.add(new GraphNode(id, label, GraphNode.NodeType.input));
inputIds.add(id);
}
}

// Step 2b – Tool nodes
Set<String> stepIds = stepsSection.keySet();
for (String stepId : stepIds) {
nodes.add(new GraphNode(stepId, toolLabel(stepId), GraphNode.NodeType.tool));
}

// Step 2c – Output nodes + remember which step feeds each output
Map<String, String> outputSources = new LinkedHashMap<>();
if (outputsSection != null) {
for (Map.Entry<String, Object> entry : outputsSection.entrySet()) {
String id = entry.getKey();
String label = formatLabel(entry.getValue(), id, labelResolver);
nodes.add(new GraphNode(id, label, GraphNode.NodeType.output));

if (entry.getValue() instanceof Map<?, ?> def) {
String src = (String) ((Map<String, Object>) def).get("outputSource");
if (src != null && src.contains("/")) {
String sourceStepId = src.substring(0, src.indexOf('/'));
if (stepIds.contains(sourceStepId)) {
outputSources.put(id, sourceStepId);
}
}
}
}
}

// Step 3 – Edges
for (String targetId : stepIds) {
Object inField = ((Map<String, Object>) stepsSection.get(targetId)).get("in");
if (!(inField instanceof Map<?, ?> rawIn)) continue;

for (Object sourceRef : ((Map<String, Object>) rawIn).values()) {
if (!(sourceRef instanceof String ref)) continue;

if (ref.contains("/")) {
// Tool → Tool
String sourceId = ref.substring(0, ref.indexOf('/'));
if (stepIds.contains(sourceId) && seen.add(sourceId + "->" + targetId)) {
edges.add(new GraphEdge(sourceId, targetId));
}
} else if (inputIds.contains(ref) && seen.add(ref + "->" + targetId)) {
// Input → Tool
edges.add(new GraphEdge(ref, targetId));
}
}
}

// Tool → Output
outputSources.forEach((outputId, sourceId) ->
edges.add(new GraphEdge(sourceId, outputId)));

// Step 4 – EDAM tuples for APE synthesis constraints
List<TaxonomyElem> inputs = extractTuples(inputsSection, labelResolver);
List<TaxonomyElem> outputs = extractTuples(outputsSection, labelResolver);

return new ParseResponse(nodes, edges, inputs, outputs);
}

private static void validateMetadata(Map<String, Object> cwl) {
String cwlClass = (String) cwl.get("class");
String cwlVersion = (String) cwl.get("cwlVersion");
if (!"Workflow".equals(cwlClass)) {
throw new IllegalArgumentException(
"CWL file must declare 'class: Workflow', found: " + cwlClass);
}
if (!"v1.2".equals(cwlVersion)) {
throw new IllegalArgumentException(
"CWL file must declare 'cwlVersion: v1.2', found: " + cwlVersion);
}
}

/** Strips the APE-generated numeric suffix (e.g. MSFragger_01 → MSFragger). */
private static String toolLabel(String stepId) {
return stepId.replaceAll("_\\d+$", "");
}

/**
* Resolves the EDAM format URI from an inputs/outputs entry to a human-readable label.
* Falls back to the port ID when no format is declared.
*/
@SuppressWarnings("unchecked")
private static String formatLabel(Object entry, String fallbackId, UnaryOperator<String> resolver) {
if (entry instanceof Map<?, ?> def) {
String uri = (String) ((Map<String, Object>) def).get("format");
if (uri != null && !uri.isBlank()) {
return resolver.apply(uri);
}
}
return fallbackId;
}

@SuppressWarnings("unchecked")
private static List<TaxonomyElem> extractTuples(Map<String, Object> section, UnaryOperator<String> resolver) {
List<TaxonomyElem> tuples = new ArrayList<>();
if (section == null) return tuples;
for (Map.Entry<String, Object> entry : section.entrySet()) {
if (!(entry.getValue() instanceof Map<?, ?> def)) continue;
String uri = (String) ((Map<String, Object>) def).get("format");
if (uri == null) continue;
tuples.add(new TaxonomyElem(uri, resolver.apply(uri), null, null));
}
return tuples;
}
}
Loading
Loading