Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 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
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
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
Comment thread
eladrion marked this conversation as resolved.
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
Comment thread
eladrion marked this conversation as resolved.
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
Comment thread
eladrion marked this conversation as resolved.
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@aaronstrachardt The SnakeYAML approach does work in principle and it was applied for some time in APE. But perhaps the specific CWL parser as used in APE could make things easier. I'm not completely sure but perhaps you could reuse the implementation from APE.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@eladrion You're right that sharing one CWL parser would be cleaner and it wouldn't add a new dependency.

I checked APE's CWLParser class. The problem is that it only parses a single CommandLineTool, so I cannot reuse it as it is. Reusing it would be possible, but only with an extension to handle workflows.

I think I will stick with the current implementation and add the unified parser as future work. Does that sound okay to you?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is okay for now and I can test it as is.

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;
}
}
54 changes: 54 additions & 0 deletions src/main/java/nl/esciencecenter/restape/EdamLabels.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package nl.esciencecenter.restape;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Component;

import java.io.InputStream;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;

/**
* Resolves EDAM URIs to human-readable labels via a pre-generated static index.
*
* The index (edam_labels.json) is bundled as a classpath resource and was generated
* once from the EDAM OWL file. Lookups are O(1) after the first call to
* {@link #ensureLoaded()}.
*/
@Component
public class EdamLabels {

private volatile Map<String, String> labels = null;

/**
* Loads the static label index if not already done. Thread-safe; subsequent
* calls return immediately.
*/
public synchronized void ensureLoaded() {
if (labels != null) return;
try (InputStream is = getClass().getClassLoader()
.getResourceAsStream("edam_labels.json")) {
if (is == null) throw new IllegalStateException("edam_labels.json not found on classpath");
Map<String, String> map = new ObjectMapper()
.readValue(is, new TypeReference<HashMap<String, String>>() {});
labels = Collections.unmodifiableMap(map);
} catch (Exception e) {
throw new IllegalStateException("Failed to load edam_labels.json: " + e.getMessage(), e);
}
}

public String resolve(String uri) {
if (uri == null || uri.isBlank()) return "";
Map<String, String> map = labels;
if (map == null) return shortForm(uri);
return map.getOrDefault(uri, shortForm(uri));
}

static String shortForm(String uri) {
if (uri == null || uri.isBlank()) return "";
int slash = uri.lastIndexOf('/');
int hash = uri.lastIndexOf('#');
return uri.substring(Math.max(slash, hash) + 1);
}
}
Loading
Loading