-
Notifications
You must be signed in to change notification settings - Fork 3
Generate alternatives #101
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
d64d1e2
66bbf89
dfb65e4
ec0e951
247f216
6eae0df
dbd5a6f
0e262a0
1223325
93f9c6b
f933834
56e577c
bd28460
90a85aa
4694bc5
53640f4
b0ce21a
1ad50ec
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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()); | ||
| } | ||
| } |
| 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; | ||
| } |
|
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 | ||
| } | ||
| } |
|
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; | ||
| } |
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
| } | ||
| } |
| 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); | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.