From 27e05f0d39a86a246c3115f13bcb005af7f3cba3 Mon Sep 17 00:00:00 2001 From: Ricardo Zanini Date: Thu, 28 Aug 2025 08:48:07 -0400 Subject: [PATCH 1/3] Introduce Mermaid exporter Signed-off-by: Ricardo Zanini --- mermaid/pom.xml | 50 +++++++ .../mermaid/DefaultNodeRenderer.java | 67 ++++++++++ .../serverlessworkflow/mermaid/EmitNode.java | 38 ++++++ .../serverlessworkflow/mermaid/ForNode.java | 60 +++++++++ .../serverlessworkflow/mermaid/ForkNode.java | 70 ++++++++++ .../io/serverlessworkflow/mermaid/Ids.java | 32 +++++ .../mermaid/IteratorNode.java | 46 +++++++ .../mermaid/ListenNode.java | 91 +++++++++++++ .../serverlessworkflow/mermaid/Mermaid.java | 54 ++++++++ .../mermaid/MermaidGraph.java | 97 ++++++++++++++ .../mermaid/MermaidRenderer.java | 29 ++++ .../io/serverlessworkflow/mermaid/Node.java | 124 ++++++++++++++++++ .../mermaid/NodeBuilder.java | 74 +++++++++++ .../mermaid/NodeRenderer.java | 33 +++++ .../serverlessworkflow/mermaid/NodeType.java | 40 ++++++ .../serverlessworkflow/mermaid/SplitNode.java | 63 +++++++++ .../mermaid/SplitNodeRenderer.java | 36 +++++ .../mermaid/SubgraphNode.java | 28 ++++ .../mermaid/SubgraphNodeRenderer.java | 48 +++++++ .../serverlessworkflow/mermaid/TaskNode.java | 66 ++++++++++ .../mermaid/TaskSubgraphNode.java | 40 ++++++ .../mermaid/TryBlockNode.java | 24 ++++ .../mermaid/TryCatchNode.java | 43 ++++++ .../mermaid/MermaidTest.java | 31 +++++ .../call-http-endpoint-interpolation.yaml | 13 ++ .../src/test/resources/samples/composite.yaml | 17 +++ mermaid/src/test/resources/samples/emit.yaml | 19 +++ mermaid/src/test/resources/samples/fork.yaml | 26 ++++ .../listen-to-any-forever-foreach.yaml | 22 ++++ .../src/test/resources/samples/pet-store.yaml | 88 +++++++++++++ .../test/resources/samples/room-readings.yaml | 44 +++++++ .../src/test/resources/samples/try-catch.yaml | 39 ++++++ pom.xml | 1 + 33 files changed, 1553 insertions(+) create mode 100644 mermaid/pom.xml create mode 100644 mermaid/src/main/java/io/serverlessworkflow/mermaid/DefaultNodeRenderer.java create mode 100644 mermaid/src/main/java/io/serverlessworkflow/mermaid/EmitNode.java create mode 100644 mermaid/src/main/java/io/serverlessworkflow/mermaid/ForNode.java create mode 100644 mermaid/src/main/java/io/serverlessworkflow/mermaid/ForkNode.java create mode 100644 mermaid/src/main/java/io/serverlessworkflow/mermaid/Ids.java create mode 100644 mermaid/src/main/java/io/serverlessworkflow/mermaid/IteratorNode.java create mode 100644 mermaid/src/main/java/io/serverlessworkflow/mermaid/ListenNode.java create mode 100644 mermaid/src/main/java/io/serverlessworkflow/mermaid/Mermaid.java create mode 100644 mermaid/src/main/java/io/serverlessworkflow/mermaid/MermaidGraph.java create mode 100644 mermaid/src/main/java/io/serverlessworkflow/mermaid/MermaidRenderer.java create mode 100644 mermaid/src/main/java/io/serverlessworkflow/mermaid/Node.java create mode 100644 mermaid/src/main/java/io/serverlessworkflow/mermaid/NodeBuilder.java create mode 100644 mermaid/src/main/java/io/serverlessworkflow/mermaid/NodeRenderer.java create mode 100644 mermaid/src/main/java/io/serverlessworkflow/mermaid/NodeType.java create mode 100644 mermaid/src/main/java/io/serverlessworkflow/mermaid/SplitNode.java create mode 100644 mermaid/src/main/java/io/serverlessworkflow/mermaid/SplitNodeRenderer.java create mode 100644 mermaid/src/main/java/io/serverlessworkflow/mermaid/SubgraphNode.java create mode 100644 mermaid/src/main/java/io/serverlessworkflow/mermaid/SubgraphNodeRenderer.java create mode 100644 mermaid/src/main/java/io/serverlessworkflow/mermaid/TaskNode.java create mode 100644 mermaid/src/main/java/io/serverlessworkflow/mermaid/TaskSubgraphNode.java create mode 100644 mermaid/src/main/java/io/serverlessworkflow/mermaid/TryBlockNode.java create mode 100644 mermaid/src/main/java/io/serverlessworkflow/mermaid/TryCatchNode.java create mode 100644 mermaid/src/test/java/io/serverlessworkflow/mermaid/MermaidTest.java create mode 100644 mermaid/src/test/resources/samples/call-http-endpoint-interpolation.yaml create mode 100644 mermaid/src/test/resources/samples/composite.yaml create mode 100644 mermaid/src/test/resources/samples/emit.yaml create mode 100644 mermaid/src/test/resources/samples/fork.yaml create mode 100644 mermaid/src/test/resources/samples/listen-to-any-forever-foreach.yaml create mode 100644 mermaid/src/test/resources/samples/pet-store.yaml create mode 100644 mermaid/src/test/resources/samples/room-readings.yaml create mode 100644 mermaid/src/test/resources/samples/try-catch.yaml diff --git a/mermaid/pom.xml b/mermaid/pom.xml new file mode 100644 index 000000000..049c9b080 --- /dev/null +++ b/mermaid/pom.xml @@ -0,0 +1,50 @@ + + + 4.0.0 + + io.serverlessworkflow + serverlessworkflow-parent + 8.0.0-SNAPSHOT + + + serverlessworkflow-mermaid + Serverless Workflow :: Mermaid + Export a Workflow Definition as a Mermaid Flowchart + + + 17 + 17 + UTF-8 + + + + + io.serverlessworkflow + serverlessworkflow-types + + + io.serverlessworkflow + serverlessworkflow-api + + + + + org.junit.jupiter + junit-jupiter-api + test + + + org.mockito + mockito-core + test + + + org.assertj + assertj-core + test + + + + \ No newline at end of file diff --git a/mermaid/src/main/java/io/serverlessworkflow/mermaid/DefaultNodeRenderer.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/DefaultNodeRenderer.java new file mode 100644 index 000000000..f5221e715 --- /dev/null +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/DefaultNodeRenderer.java @@ -0,0 +1,67 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed 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 io.serverlessworkflow.mermaid; + +public class DefaultNodeRenderer implements NodeRenderer { + + private final Node node; + protected String renderedArrow = "-->"; + + public DefaultNodeRenderer(Node node) { + this.node = node; + } + + protected final Node getNode() { + return node; + } + + @Override + public void setRenderedArrow(String renderedArrow) { + this.renderedArrow = renderedArrow; + } + + public void render(StringBuilder sb, int level) { + sb.append(ind(level)) + .append(node.id) + .append("@{ shape: ") + .append(node.type.mermaidShape()) + .append(", label: \"") + .append(NodeRenderer.escNodeLabel(node.label)) + .append("\" }\n"); + this.renderBody(sb, level); + this.renderNext(sb, level); + } + + protected void renderBody(StringBuilder sb, int level) { + if (!this.node.branches.isEmpty()) { + MermaidRenderer.render(this.getNode().getBranches(), sb, level + 1); + } + } + + protected void renderNext(StringBuilder sb, int level) { + if (node.getNext() != null) { + sb.append(ind(level)) + .append(node.getId()) + .append(renderedArrow) + .append(node.getNext().getId()) + .append("\n"); + } + } + + protected String ind(int level) { + return " ".repeat(level * 4); // 4 spaces per level + } +} diff --git a/mermaid/src/main/java/io/serverlessworkflow/mermaid/EmitNode.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/EmitNode.java new file mode 100644 index 000000000..d355a4678 --- /dev/null +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/EmitNode.java @@ -0,0 +1,38 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed 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 io.serverlessworkflow.mermaid; + +import io.serverlessworkflow.api.types.EmitTask; +import io.serverlessworkflow.api.types.TaskItem; + +public class EmitNode extends TaskNode { + + public EmitNode(TaskItem task) { + super("emit", task, NodeType.EMIT); + + if (task.getTask().getEmitTask() == null) { + throw new IllegalStateException("Emit node must have a emit task"); + } + + EmitTask emitTask = task.getTask().getEmitTask(); + + if (emitTask.getEmit().getEvent() == null) { + return; + } + + this.label = String.format("emit: **%s**", emitTask.getEmit().getEvent().getWith().getType()); + } +} diff --git a/mermaid/src/main/java/io/serverlessworkflow/mermaid/ForNode.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/ForNode.java new file mode 100644 index 000000000..c58e41383 --- /dev/null +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/ForNode.java @@ -0,0 +1,60 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed 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 io.serverlessworkflow.mermaid; + +import io.serverlessworkflow.api.types.ForTask; +import io.serverlessworkflow.api.types.TaskItem; + +public class ForNode extends TaskSubgraphNode { + + ForNode(TaskItem task) { + super(task, String.format("for: %s", task.getName())); + + if (task.getTask().getForTask() == null) { + throw new IllegalStateException("For node must have a for task"); + } + + final ForTask forTask = task.getTask().getForTask(); + + if (forTask.getDo().isEmpty()) { + return; + } + + String noteLabel = + String.format( + "• each: %s
• in: %s
• at: %s", + forTask.getFor().getEach(), forTask.getFor().getIn(), forTask.getFor().getAt()); + Node note = NodeBuilder.note(noteLabel); + this.addBranch(note.getId(), note); + + Node loop = NodeBuilder.split(); + this.addBranch(loop.getId(), loop); + + this.branches.putAll(new MermaidGraph().build(forTask.getDo())); + final Node firstTask = this.branches.get(forTask.getDo().get(0).getName()); + + note.setNext(loop); + loop.setNext(firstTask); + + String lastForTask = forTask.getDo().get(forTask.getDo().size() - 1).getName(); + String renderedArrow = "-. |next| .->"; + if (forTask.getWhile() != null && !forTask.getWhile().isEmpty()) { + renderedArrow = "-. |while: " + NodeRenderer.escNodeLabel(forTask.getWhile()) + "| .->"; + } + + this.getBranches().get(lastForTask).withNext(loop).setRenderedArrow(renderedArrow); + } +} diff --git a/mermaid/src/main/java/io/serverlessworkflow/mermaid/ForkNode.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/ForkNode.java new file mode 100644 index 000000000..1b66844c1 --- /dev/null +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/ForkNode.java @@ -0,0 +1,70 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed 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 io.serverlessworkflow.mermaid; + +import io.serverlessworkflow.api.types.ForkTask; +import io.serverlessworkflow.api.types.TaskItem; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public class ForkNode extends TaskSubgraphNode { + + public ForkNode(TaskItem task) { + super(task, String.format("fork: %s", task.getName())); + + if (task.getTask().getForkTask() == null) { + throw new IllegalStateException("Fork node must have a fork task"); + } + + ForkTask fork = task.getTask().getForkTask(); + this.setDirection("LR"); + + // Split and join badges + SplitNode split = NodeBuilder.split(); + String competeLabel = fork.getFork().isCompete() ? "ANY" : "ALL"; + Node join = new Node(Ids.newId(), competeLabel, NodeType.JUNCTION); + this.addBranch(split.getId(), split); + this.addBranch(join.getId(), join); + + // Build each branch as its own (sub)graph + List branches = fork.getFork().getBranches(); + Map branchRoots = new LinkedHashMap<>(); + for (TaskItem branchTask : branches) { + // render branch as a titled subgraph with its inner tasks + String branchTitle = branchTask.getName(); + Node branchNode; + + if (branchTask.getTask().getDoTask() != null) { + branchNode = + new TaskSubgraphNode(branchTask, branchTitle) + .withBranches(branchTask.getTask().getDoTask().getDo()); + } else { + branchNode = NodeBuilder.task(branchTask); + } + branchRoots.put(branchTitle, branchNode); + this.addBranch(branchTitle, branchNode); + } + + for (TaskItem branchRoot : branches) { + String name = branchRoot.getName(); + Node branch = branchRoots.get(name); + split.addNext(branch); + branch.setNext(join); + branch.setRenderedArrow("-- |" + competeLabel + "| -->"); + } + } +} diff --git a/mermaid/src/main/java/io/serverlessworkflow/mermaid/Ids.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/Ids.java new file mode 100644 index 000000000..051df4517 --- /dev/null +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/Ids.java @@ -0,0 +1,32 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed 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 io.serverlessworkflow.mermaid; + +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.atomic.AtomicInteger; + +public final class Ids { + private final String salt = Integer.toString(ThreadLocalRandom.current().nextInt(), 36); + private final AtomicInteger seq = new AtomicInteger(); + + private String build() { + return "n_" + salt + "_" + Integer.toString(seq.getAndIncrement(), 36); + } + + public static String newId() { + return new Ids().build(); + } +} diff --git a/mermaid/src/main/java/io/serverlessworkflow/mermaid/IteratorNode.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/IteratorNode.java new file mode 100644 index 000000000..85219549c --- /dev/null +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/IteratorNode.java @@ -0,0 +1,46 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed 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 io.serverlessworkflow.mermaid; + +import io.serverlessworkflow.api.types.SubscriptionIterator; + +public class IteratorNode extends SubgraphNode { + + public IteratorNode(String label, SubscriptionIterator iterator) { + super(Ids.newId(), label); + + if (iterator.getDo().isEmpty()) { + return; + } + + Node note = NodeBuilder.note(String.format("• at: %s", iterator.getAt())); + this.addBranch(note.getId(), note); + + Node loop = NodeBuilder.junction(); + this.addBranch(loop.getId(), loop); + + this.branches.putAll(new MermaidGraph().build(iterator.getDo())); + final Node firstTask = this.branches.get(iterator.getDo().get(0).getName()); + + note.setNext(loop); + loop.setNext(firstTask); + + String lastForTask = iterator.getDo().get(iterator.getDo().size() - 1).getName(); + String renderedArrow = "-. |next| .->"; + + this.getBranches().get(lastForTask).withNext(loop).setRenderedArrow(renderedArrow); + } +} diff --git a/mermaid/src/main/java/io/serverlessworkflow/mermaid/ListenNode.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/ListenNode.java new file mode 100644 index 000000000..cdc2183c5 --- /dev/null +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/ListenNode.java @@ -0,0 +1,91 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed 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 io.serverlessworkflow.mermaid; + +import io.serverlessworkflow.api.types.ListenTask; +import io.serverlessworkflow.api.types.ListenTo; +import io.serverlessworkflow.api.types.TaskItem; +import java.util.ArrayList; +import java.util.List; + +public class ListenNode extends TaskSubgraphNode { + + public ListenNode(TaskItem task) { + super(task, "listen", NodeType.SUBGRAPH); + + if (task.getTask().getListenTask() == null) { + throw new IllegalStateException("Listen node must have a listen task"); + } + + ListenTask listenTask = task.getTask().getListenTask(); + + String strategy = "ALL"; + String junctionArrow = ""; + List events = new ArrayList<>(); + ListenTo to = listenTask.getListen().getTo(); + if (to.getAnyEventConsumptionStrategy() != null) { + strategy = "ANY"; + to.getAnyEventConsumptionStrategy().getAny().stream() + .map(e -> e.getWith().getType()) + .forEach(events::add); + if (to.getAnyEventConsumptionStrategy().getUntil() != null) { + junctionArrow = + String.format( + "-. until: %s .->", + NodeRenderer.escNodeLabel( + to.getAnyEventConsumptionStrategy().getUntil().get().toString())); + } + } else if (to.getOneEventConsumptionStrategy() != null) { + strategy = "ONE"; + events.add(to.getOneEventConsumptionStrategy().getOne().getWith().getType()); + } else if (to.getAllEventConsumptionStrategy() != null) { + to.getAllEventConsumptionStrategy().getAll().stream() + .map(e -> e.getWith().getType()) + .forEach(events::add); + } + + String noteLabel = String.format("to %s events", strategy); + if (!events.isEmpty()) { + noteLabel = String.format("%s:
• %s", noteLabel, String.join("
• ", events)); + } + + Node nodeNote = NodeBuilder.note(noteLabel); + Node junctionNote = NodeBuilder.junction(); + Node inner = NodeBuilder.rect(task.getName()); + + junctionNote.withNext(inner); + nodeNote.withNext(junctionNote); + + this.addBranch("note", nodeNote); + this.addBranch("junction", junctionNote); + this.addBranch(inner.getLabel(), inner); + + if (listenTask.getForeach() != null + && listenTask.getForeach().getDo() != null + && !listenTask.getForeach().getDo().isEmpty()) { + Node forEach = new IteratorNode("for:", listenTask.getForeach()); + this.addBranch("forEach", forEach); + inner.setNext(forEach); + forEach.setNext(junctionNote); + + if (!junctionArrow.isEmpty()) { + forEach.setRenderedArrow(junctionArrow); + } + } else if (!junctionArrow.isEmpty()) { + inner.withNext(junctionNote).setRenderedArrow(junctionArrow); + } + } +} diff --git a/mermaid/src/main/java/io/serverlessworkflow/mermaid/Mermaid.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/Mermaid.java new file mode 100644 index 000000000..e2cde7a49 --- /dev/null +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/Mermaid.java @@ -0,0 +1,54 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed 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 io.serverlessworkflow.mermaid; + +import io.serverlessworkflow.api.WorkflowReader; +import io.serverlessworkflow.api.types.Workflow; +import java.io.IOException; +import java.util.Map; + +public class Mermaid { + + private static final String FLOWCHART = "flowchart TD\n"; + + public String from(Workflow workflow) { + if (workflow == null || workflow.getDo().isEmpty()) { + return ""; + } + + final StringBuilder sb = new StringBuilder(); + this.header(sb); + + final Map graph = new MermaidGraph().buildWithTerminals(workflow.getDo()); + MermaidRenderer.render(graph, sb, 1); + + return sb.toString(); + } + + public String from(String classpathLocation) throws IOException { + return this.from(WorkflowReader.readWorkflowFromClasspath(classpathLocation)); + } + + private void header(StringBuilder sb) { + // TODO: make a config builder + sb.append("---\n") + .append("config:\n") + .append(" look: handDrawn\n") + .append(" theme: base\n") + .append("---\n") + .append(FLOWCHART); + } +} diff --git a/mermaid/src/main/java/io/serverlessworkflow/mermaid/MermaidGraph.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/MermaidGraph.java new file mode 100644 index 000000000..a0910e88d --- /dev/null +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/MermaidGraph.java @@ -0,0 +1,97 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed 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 io.serverlessworkflow.mermaid; + +import io.serverlessworkflow.api.types.CallTask; +import io.serverlessworkflow.api.types.FlowDirective; +import io.serverlessworkflow.api.types.FlowDirectiveEnum; +import io.serverlessworkflow.api.types.TaskBase; +import io.serverlessworkflow.api.types.TaskItem; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +final class MermaidGraph { + + MermaidGraph() {} + + private static FlowDirective extractThen(TaskItem task) { + TaskBase taskBase = toTaskBase(task); + if (taskBase == null) { + return null; + } + return taskBase.getThen(); + } + + private static TaskBase toTaskBase(TaskItem task) { + if (task.getTask() == null || task.getTask().get() == null) { + return null; + } + if (task.getTask().get() instanceof CallTask) { + return (TaskBase) ((CallTask) task.getTask().get()).get(); + } + return (TaskBase) task.getTask().get(); + } + + Map buildWithTerminals(List tasks) { + final Map graph = this.build(tasks); + final Node startNode = new Node(Ids.newId(), "__start", NodeType.START); + final Node endNode = new Node(Ids.newId(), "__end", NodeType.STOP); + for (Node n : graph.values()) { + if (n.getNext() == null && n.getType() != NodeType.START && n.getType() != NodeType.STOP) { + n.setNext(endNode); + } + } + graph.put("start", startNode.withNext(graph.get(tasks.get(0).getName()))); + graph.put("end", endNode); + return graph; + } + + Map build(List tasks) { + Map graph = new LinkedHashMap<>(Math.max(16, tasks.size() * 2)); + + for (int i = 0; i < tasks.size(); i++) { + TaskItem task = tasks.get(i); + Node u = graph.computeIfAbsent(task.getName(), n -> NodeBuilder.task(task)); + FlowDirective next = extractThen(task); + if ((next == null || FlowDirectiveEnum.CONTINUE.equals(next.getFlowDirectiveEnum())) + && (i + 1 < tasks.size())) { + TaskItem nextTask = tasks.get(i + 1); + Node v = graph.computeIfAbsent(nextTask.getName(), n -> NodeBuilder.task(nextTask)); + u.setNext(v); + } + } + + for (TaskItem cur : tasks) { + FlowDirective then = extractThen(cur); + if (then != null && then.getString() != null) { + Node from = graph.get(cur.getName()); + Node to = graph.get(then.getString()); + if (to == null) { + throw new IllegalStateException( + "then -> '" + + then.getString() + + "' not found in this task list (from '" + + cur.getName() + + "')"); + } + from.setNext(to); + } + } + + return graph; + } +} diff --git a/mermaid/src/main/java/io/serverlessworkflow/mermaid/MermaidRenderer.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/MermaidRenderer.java new file mode 100644 index 000000000..07f6c2f6a --- /dev/null +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/MermaidRenderer.java @@ -0,0 +1,29 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed 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 io.serverlessworkflow.mermaid; + +import java.util.Map; + +public final class MermaidRenderer { + + private MermaidRenderer() {} + + static void render(Map graph, StringBuilder sb, int level) { + for (Node node : graph.values()) { + node.render(sb, level); + } + } +} diff --git a/mermaid/src/main/java/io/serverlessworkflow/mermaid/Node.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/Node.java new file mode 100644 index 000000000..0c4095e94 --- /dev/null +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/Node.java @@ -0,0 +1,124 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed 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 io.serverlessworkflow.mermaid; + +import java.io.Serializable; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +public class Node implements Serializable { + protected final String id; + protected String label; + protected Node next; + protected NodeType type; + protected Map branches; + protected NodeRenderer renderer; + + public Node(String id, String label) { + this.id = id; + this.label = label; + this.type = NodeType.RECT; + this.branches = new LinkedHashMap<>(); + this.renderer = new DefaultNodeRenderer(this); + } + + public Node(String id, String label, NodeType type) { + this(id, label); + this.type = type; + } + + public Node withNext(Node next) { + this.next = next; + return this; + } + + public NodeType getType() { + return type; + } + + public Node getNext() { + return next; + } + + public void setNext(Node next) { + this.next = next; + } + + public String getId() { + return id; + } + + public String getLabel() { + return NodeRenderer.escNodeLabel(label); + } + + public void setLabel(String label) { + this.label = label; + } + + public void addBranch(String name, Node branch) { + branches.put(name, branch); + } + + public void addBranches(Map branches) { + this.branches.putAll(branches); + } + + public Map getBranches() { + return branches; + } + + public void setRenderedArrow(String renderedArrow) { + this.renderer.setRenderedArrow(renderedArrow); + } + + /** Renders the Mermaid representation of this node. */ + public void render(StringBuilder sb, int level) { + renderer.render(sb, level); + } + + @Override + public String toString() { + return "Node{" + + "type=" + + type + + ", next=" + + next + + ", label='" + + label + + '\'' + + ", id='" + + id + + '\'' + + '}'; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + Node node = (Node) o; + return Objects.equals(id, node.id) + && Objects.equals(label, node.label) + && Objects.equals(next, node.next) + && type == node.type; + } + + @Override + public int hashCode() { + return Objects.hash(id, label, next, type); + } +} diff --git a/mermaid/src/main/java/io/serverlessworkflow/mermaid/NodeBuilder.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/NodeBuilder.java new file mode 100644 index 000000000..0ca4c74ab --- /dev/null +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/NodeBuilder.java @@ -0,0 +1,74 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed 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 io.serverlessworkflow.mermaid; + +import io.serverlessworkflow.api.types.DoTask; +import io.serverlessworkflow.api.types.EmitTask; +import io.serverlessworkflow.api.types.ForTask; +import io.serverlessworkflow.api.types.ForkTask; +import io.serverlessworkflow.api.types.ListenTask; +import io.serverlessworkflow.api.types.TaskItem; +import io.serverlessworkflow.api.types.TryTask; + +public final class NodeBuilder { + + private NodeBuilder() {} + + public static Node note(String label) { + Node node = new Node(Ids.newId(), label, NodeType.NOTE); + node.setRenderedArrow("-.->"); + return node; + } + + public static Node junction() { + return new Node(Ids.newId(), "join", NodeType.JUNCTION); + } + + public static SplitNode split() { + return new SplitNode(Ids.newId(), "split"); + } + + public static Node rect(String label) { + return new Node(Ids.newId(), label, NodeType.RECT); + } + + public static Node tryBlock() { + return new TryBlockNode(); + } + + public static Node subgraph(String label) { + return new SubgraphNode(Ids.newId(), label); + } + + public static Node task(TaskItem task) { + if (task.getTask().get() instanceof TryTask) { + return new TryCatchNode(task); + } else if (task.getTask().get() instanceof DoTask) { + return new TaskSubgraphNode(task, String.format("do: %s", task.getName())) + .withBranches(task.getTask().getDoTask().getDo()); + } else if (task.getTask().get() instanceof ForTask) { + return new ForNode(task); + } else if (task.getTask().get() instanceof ListenTask) { + return new ListenNode(task); + } else if (task.getTask().get() instanceof EmitTask) { + return new EmitNode(task); + } else if (task.getTask().get() instanceof ForkTask) { + return new ForkNode(task); + } + // TODO: Switch, Raise, Run, Set, Wait, Call + return new TaskNode(task.getName(), task); + } +} diff --git a/mermaid/src/main/java/io/serverlessworkflow/mermaid/NodeRenderer.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/NodeRenderer.java new file mode 100644 index 000000000..60424b30e --- /dev/null +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/NodeRenderer.java @@ -0,0 +1,33 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed 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 io.serverlessworkflow.mermaid; + +public interface NodeRenderer { + + void render(StringBuilder sb, int level); + + void setRenderedArrow(String renderedArrow); + + static String escNodeLabel(String s) { + if (s == null) return ""; + return s.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("]", "\\]") + .replace("[", "\\[") + .replace("\r\n", "
") + .replace("\n", "
"); + } +} diff --git a/mermaid/src/main/java/io/serverlessworkflow/mermaid/NodeType.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/NodeType.java new file mode 100644 index 000000000..7ed9c12f5 --- /dev/null +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/NodeType.java @@ -0,0 +1,40 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed 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 io.serverlessworkflow.mermaid; + +public enum NodeType { + RECT("rect"), + STOP("stop"), + SUBGRAPH("subgraph"), + TRY_CATCH("subgraph"), + TRY_BLOCK("subgraph"), + NOTE("note"), + SPLIT("sm-circ"), + START("sm-circ"), + EVENT("rounded"), + EMIT("lean-r"), + JUNCTION("f-circ"); + + private final String type; + + NodeType(String type) { + this.type = type; + } + + public String mermaidShape() { + return this.type; + } +} diff --git a/mermaid/src/main/java/io/serverlessworkflow/mermaid/SplitNode.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/SplitNode.java new file mode 100644 index 000000000..5588bbcd9 --- /dev/null +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/SplitNode.java @@ -0,0 +1,63 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed 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 io.serverlessworkflow.mermaid; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +/** Split nodes can have more than one exit point */ +public class SplitNode extends Node { + + private final Set nexts = new HashSet<>(); + + public SplitNode(String id, String label) { + super(id, label, NodeType.SPLIT); + this.renderer = new SplitNodeRenderer(this); + } + + public void addNext(Node next) { + nexts.add(next); + } + + /** + * Same as #addNext + * + * @param next + */ + @Override + public void setNext(Node next) { + this.addNext(next); + super.setNext(next); + } + + /** + * Gets the first next in the set + * + * @return + */ + @Override + public Node getNext() { + if (this.nexts.isEmpty()) { + return null; + } + return this.nexts.iterator().next(); + } + + public Set getNexts() { + return Collections.unmodifiableSet(nexts); + } +} diff --git a/mermaid/src/main/java/io/serverlessworkflow/mermaid/SplitNodeRenderer.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/SplitNodeRenderer.java new file mode 100644 index 000000000..6348952ce --- /dev/null +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/SplitNodeRenderer.java @@ -0,0 +1,36 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed 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 io.serverlessworkflow.mermaid; + +public class SplitNodeRenderer extends DefaultNodeRenderer { + + public SplitNodeRenderer(SplitNode node) { + super(node); + } + + @Override + protected void renderNext(StringBuilder sb, int level) { + SplitNode splitNode = (SplitNode) this.getNode(); + + for (Node next : splitNode.getNexts()) { + sb.append(ind(level)) + .append(this.getNode().getId()) + .append(renderedArrow) + .append(next.getId()) + .append("\n"); + } + } +} diff --git a/mermaid/src/main/java/io/serverlessworkflow/mermaid/SubgraphNode.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/SubgraphNode.java new file mode 100644 index 000000000..b38c3c39f --- /dev/null +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/SubgraphNode.java @@ -0,0 +1,28 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed 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 io.serverlessworkflow.mermaid; + +public class SubgraphNode extends Node { + + public SubgraphNode(String id, String label) { + this(id, label, NodeType.SUBGRAPH); + } + + public SubgraphNode(String id, String label, NodeType type) { + super(id, label, type); + this.renderer = new SubgraphNodeRenderer(this); + } +} diff --git a/mermaid/src/main/java/io/serverlessworkflow/mermaid/SubgraphNodeRenderer.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/SubgraphNodeRenderer.java new file mode 100644 index 000000000..dc7577015 --- /dev/null +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/SubgraphNodeRenderer.java @@ -0,0 +1,48 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed 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 io.serverlessworkflow.mermaid; + +public class SubgraphNodeRenderer extends DefaultNodeRenderer implements NodeRenderer { + + private String direction = "TB"; + + public SubgraphNodeRenderer(Node node) { + super(node); + } + + public final void setDirection(String direction) { + this.direction = direction; + } + + @Override + public void render(StringBuilder sb, int level) { + sb.append(ind(level)) + .append("subgraph ") + .append(getNode().getId()) + .append("[\"") + .append(NodeRenderer.escNodeLabel(getNode().getLabel())) + .append("\"]\n"); + this.renderBody(sb, level); + this.renderNext(sb, level); + } + + @Override + protected void renderBody(StringBuilder sb, int level) { + sb.append(ind(level + 1)).append("direction ").append(direction).append("\n"); + MermaidRenderer.render(this.getNode().getBranches(), sb, level + 1); + sb.append(ind(level)).append("end\n"); + } +} diff --git a/mermaid/src/main/java/io/serverlessworkflow/mermaid/TaskNode.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/TaskNode.java new file mode 100644 index 000000000..6f8351b11 --- /dev/null +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/TaskNode.java @@ -0,0 +1,66 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed 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 io.serverlessworkflow.mermaid; + +import io.serverlessworkflow.api.types.CallTask; +import io.serverlessworkflow.api.types.DoTask; +import io.serverlessworkflow.api.types.EmitTask; +import io.serverlessworkflow.api.types.ForTask; +import io.serverlessworkflow.api.types.ForkTask; +import io.serverlessworkflow.api.types.ListenTask; +import io.serverlessworkflow.api.types.RaiseTask; +import io.serverlessworkflow.api.types.RunTask; +import io.serverlessworkflow.api.types.SetTask; +import io.serverlessworkflow.api.types.SwitchTask; +import io.serverlessworkflow.api.types.TaskItem; +import io.serverlessworkflow.api.types.TryTask; +import io.serverlessworkflow.api.types.WaitTask; +import java.util.Map; + +public class TaskNode extends Node { + + protected static final Map, NodeType> NODE_TYPE_BY_CLASS = + Map.ofEntries( + Map.entry(CallTask.class, NodeType.RECT), + Map.entry(DoTask.class, NodeType.SUBGRAPH), + Map.entry(ForkTask.class, NodeType.SUBGRAPH), + Map.entry(EmitTask.class, NodeType.EMIT), + Map.entry(ForTask.class, NodeType.SUBGRAPH), + Map.entry(ListenTask.class, NodeType.EVENT), + Map.entry(RaiseTask.class, NodeType.RECT), + Map.entry(RunTask.class, NodeType.RECT), + Map.entry(SetTask.class, NodeType.RECT), + Map.entry(SwitchTask.class, NodeType.SUBGRAPH), + Map.entry(TryTask.class, NodeType.TRY_CATCH), + Map.entry(WaitTask.class, NodeType.RECT)); + protected final TaskItem task; + + public TaskNode(String label, TaskItem task) { + super(Ids.newId(), label); + this.task = task; + + Object concrete = task.getTask().get(); + Class cls = concrete.getClass(); + + this.type = NODE_TYPE_BY_CLASS.getOrDefault(cls, NodeType.RECT); + } + + public TaskNode(String label, TaskItem task, NodeType type) { + super(Ids.newId(), label); + this.task = task; + this.type = type; + } +} diff --git a/mermaid/src/main/java/io/serverlessworkflow/mermaid/TaskSubgraphNode.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/TaskSubgraphNode.java new file mode 100644 index 000000000..1f9239c8c --- /dev/null +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/TaskSubgraphNode.java @@ -0,0 +1,40 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed 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 io.serverlessworkflow.mermaid; + +import io.serverlessworkflow.api.types.TaskItem; +import java.util.List; + +public class TaskSubgraphNode extends TaskNode { + + public TaskSubgraphNode(TaskItem task, String label, NodeType type) { + super(label, task, type); + this.renderer = new SubgraphNodeRenderer(this); + } + + public TaskSubgraphNode(TaskItem task, String label) { + this(task, label, NodeType.SUBGRAPH); + } + + public void setDirection(String direction) { + ((SubgraphNodeRenderer) this.renderer).setDirection(direction); + } + + public TaskSubgraphNode withBranches(List branches) { + this.addBranches(new MermaidGraph().build(branches)); + return this; + } +} diff --git a/mermaid/src/main/java/io/serverlessworkflow/mermaid/TryBlockNode.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/TryBlockNode.java new file mode 100644 index 000000000..647875437 --- /dev/null +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/TryBlockNode.java @@ -0,0 +1,24 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed 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 io.serverlessworkflow.mermaid; + +public class TryBlockNode extends SubgraphNode { + + public TryBlockNode() { + super(Ids.newId(), "Try", NodeType.TRY_BLOCK); + this.renderer.setRenderedArrow("-. |onError| .->"); + } +} diff --git a/mermaid/src/main/java/io/serverlessworkflow/mermaid/TryCatchNode.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/TryCatchNode.java new file mode 100644 index 000000000..5753a0c5a --- /dev/null +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/TryCatchNode.java @@ -0,0 +1,43 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed 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 io.serverlessworkflow.mermaid; + +import io.serverlessworkflow.api.types.TaskItem; + +public class TryCatchNode extends TaskSubgraphNode { + + public TryCatchNode(TaskItem task) { + super(task, String.format("try: %s", task.getName()), NodeType.TRY_CATCH); + ((SubgraphNodeRenderer) this.renderer).setDirection("LR"); + + if (task.getTask().getTryTask() == null) { + throw new IllegalStateException("TryCatch node must have a try task"); + } + + final Node tryNode = NodeBuilder.tryBlock(); + tryNode.addBranches(new MermaidGraph().build(task.getTask().getTryTask().getTry())); + this.addBranch("try_lane", tryNode); + + if (task.getTask().getTryTask().getCatch() != null) { + final Node catchNode = NodeBuilder.subgraph("Catch"); + catchNode.addBranches( + new MermaidGraph().build(task.getTask().getTryTask().getCatch().getDo())); + this.addBranch("catch_lane", catchNode); + + tryNode.setNext(catchNode); + } + } +} diff --git a/mermaid/src/test/java/io/serverlessworkflow/mermaid/MermaidTest.java b/mermaid/src/test/java/io/serverlessworkflow/mermaid/MermaidTest.java new file mode 100644 index 000000000..98177620e --- /dev/null +++ b/mermaid/src/test/java/io/serverlessworkflow/mermaid/MermaidTest.java @@ -0,0 +1,31 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed 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 io.serverlessworkflow.mermaid; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import org.junit.jupiter.api.Test; + +public class MermaidTest { + + @Test + public void basic_check_whenBasicFlow() throws IOException { + final String renderedWf = new Mermaid().from("samples/fork.yaml"); + + assertThat(renderedWf).isNotBlank(); + } +} diff --git a/mermaid/src/test/resources/samples/call-http-endpoint-interpolation.yaml b/mermaid/src/test/resources/samples/call-http-endpoint-interpolation.yaml new file mode 100644 index 000000000..b72fa8804 --- /dev/null +++ b/mermaid/src/test/resources/samples/call-http-endpoint-interpolation.yaml @@ -0,0 +1,13 @@ +document: + dsl: '1.0.0' + namespace: examples + name: call-http-shorthand-endpoint + version: '0.1.0' +do: + - getPet: + call: http + with: + headers: + content-type: application/json + method: get + endpoint: ${ "https://petstore.swagger.io/v2/pet/\(.petId)" } diff --git a/mermaid/src/test/resources/samples/composite.yaml b/mermaid/src/test/resources/samples/composite.yaml new file mode 100644 index 000000000..6961b5787 --- /dev/null +++ b/mermaid/src/test/resources/samples/composite.yaml @@ -0,0 +1,17 @@ +document: + dsl: '1.0.0' + namespace: default + name: do + version: '1.0.0' +do: + - compositeExample: + do: + - setRed: + set: + colors: ${ .colors + ["red"] } + - setGreen: + set: + colors: ${ .colors + ["green"] } + - setBlue: + set: + colors: ${ .colors + ["blue"] } \ No newline at end of file diff --git a/mermaid/src/test/resources/samples/emit.yaml b/mermaid/src/test/resources/samples/emit.yaml new file mode 100644 index 000000000..82fe2823f --- /dev/null +++ b/mermaid/src/test/resources/samples/emit.yaml @@ -0,0 +1,19 @@ +document: + dsl: '1.0.0' + namespace: test + name: emit + version: '0.1.0' +do: + - emitEvent: + emit: + event: + with: + source: https://petstore.com + type: com.petstore.order.placed.v1 + data: + client: + firstName: Cruella + lastName: de Vil + items: + - breed: dalmatian + quantity: 101 \ No newline at end of file diff --git a/mermaid/src/test/resources/samples/fork.yaml b/mermaid/src/test/resources/samples/fork.yaml new file mode 100644 index 000000000..419346242 --- /dev/null +++ b/mermaid/src/test/resources/samples/fork.yaml @@ -0,0 +1,26 @@ +document: + dsl: '1.0.0' + namespace: test + name: fork-example + version: '0.1.0' +do: + - raiseAlarm: + fork: + compete: true + branches: + - callNurse: + call: http + with: + method: put + endpoint: https://fake-hospital.com/api/v3/alert/nurses + body: + patientId: ${ .patient.fullName } + room: ${ .room.number } + - callDoctor: + call: http + with: + method: put + endpoint: https://fake-hospital.com/api/v3/alert/doctor + body: + patientId: ${ .patient.fullName } + room: ${ .room.number } \ No newline at end of file diff --git a/mermaid/src/test/resources/samples/listen-to-any-forever-foreach.yaml b/mermaid/src/test/resources/samples/listen-to-any-forever-foreach.yaml new file mode 100644 index 000000000..53f93bc98 --- /dev/null +++ b/mermaid/src/test/resources/samples/listen-to-any-forever-foreach.yaml @@ -0,0 +1,22 @@ +document: + dsl: '1.0.0' + namespace: test + name: listen-to-any-while-foreach + version: '0.1.0' +do: + - listenToGossips: + listen: + to: + any: [] + until: '${ false }' + foreach: + item: event + at: i + do: + - postToChatApi: + call: http + with: + method: post + endpoint: https://fake-chat-api.com/room/{roomId} + body: + event: ${ $event } \ No newline at end of file diff --git a/mermaid/src/test/resources/samples/pet-store.yaml b/mermaid/src/test/resources/samples/pet-store.yaml new file mode 100644 index 000000000..425369f1b --- /dev/null +++ b/mermaid/src/test/resources/samples/pet-store.yaml @@ -0,0 +1,88 @@ +document: + dsl: '1.0.0' + namespace: test + name: order-pet + version: '0.1.0' + title: Order Pet - 1.0.0 + summary: > + # Order Pet - 1.0.0 + ## Table of Contents + - [Description](#description) + - [Requirements](#requirements) + ## Description + A sample workflow used to process an hypothetic pet order using the [PetStore API](https://petstore.swagger.io/) + ## Requirements + ### Secrets + - my-oauth2-secret +use: + authentications: + petStoreOAuth2: + oauth2: + authority: https://petstore.swagger.io/.well-known/openid-configuration + grant: client_credentials + client: + id: workflow-runtime + secret: "**********" + scopes: [ api ] + audiences: [ runtime ] + extensions: + - externalLogging: + extend: all + before: + - sendLog: + call: http + with: + method: post + endpoint: https://fake.log.collector.com + body: + message: ${ "Executing task '\($task.reference)'..." } + after: + - sendLog: + call: http + with: + method: post + endpoint: https://fake.log.collector.com + body: + message: ${ "Executed task '\($task.reference)'..." } + functions: + getAvailablePets: + call: openapi + with: + document: + endpoint: https://petstore.swagger.io/v2/swagger.json + operationId: findByStatus + parameters: + status: available + secrets: + - my-oauth2-secret +do: + - getAvailablePets: + call: getAvailablePets + output: + as: "$input + { availablePets: [.[] | select(.category.name == \"dog\" and (.tags[] | .breed == $input.order.breed))] }" + - submitMatchesByMail: + call: http + with: + method: post + endpoint: + uri: https://fake.smtp.service.com/email/send + authentication: + use: petStoreOAuth2 + body: + from: noreply@fake.petstore.com + to: ${ .order.client.email } + subject: Candidates for Adoption + body: > + Hello ${ .order.client.preferredDisplayName }! + + Following your interest to adopt a dog, here is a list of candidates that you might be interested in: + + ${ .pets | map("-\(.name)") | join("\n") } + + Please do not hesistate to contact us at info@fake.petstore.com if your have questions. + + Hope to hear from you soon! + + ---------------------------------------------------------------------------------------------- + DO NOT REPLY + ---------------------------------------------------------------------------------------------- \ No newline at end of file diff --git a/mermaid/src/test/resources/samples/room-readings.yaml b/mermaid/src/test/resources/samples/room-readings.yaml new file mode 100644 index 000000000..a41e5d538 --- /dev/null +++ b/mermaid/src/test/resources/samples/room-readings.yaml @@ -0,0 +1,44 @@ +document: + dsl: '1.0.0' + namespace: examples + name: accumulate-room-readings + version: '0.1.0' +do: + - consumeReading: + listen: + to: + all: + - with: + source: https://my.home.com/sensor + type: my.home.sensors.temperature + correlate: + roomId: + from: .roomid + - with: + source: https://my.home.com/sensor + type: my.home.sensors.humidity + correlate: + roomId: + from: .roomid + output: + as: .data.reading + - logReading: + for: + each: reading + in: .readings + do: + - callOrderService: + call: openapi + with: + document: + endpoint: http://myorg.io/ordersservices.json + operationId: logreading + - generateReport: + call: openapi + with: + document: + endpoint: http://myorg.io/ordersservices.json + operationId: produceReport +timeout: + after: + hours: 1 \ No newline at end of file diff --git a/mermaid/src/test/resources/samples/try-catch.yaml b/mermaid/src/test/resources/samples/try-catch.yaml new file mode 100644 index 000000000..56957fe48 --- /dev/null +++ b/mermaid/src/test/resources/samples/try-catch.yaml @@ -0,0 +1,39 @@ +document: + dsl: '1.0.0' + namespace: default + name: try-catch + version: '0.1.0' +do: + - tryGetPet: + try: + - getPet: + call: http + with: + method: get + endpoint: https://petstore.swagger.io/v2/pet/{petId} + catch: + errors: + with: + type: https://serverlessworkflow.io/spec/1.0.0/errors/communication + status: 404 + as: error + do: + - notifySupport: + emit: + event: + with: + source: https://petstore.swagger.io + type: io.swagger.petstore.events.pets.not-found.v1 + data: ${ $error } + - setError: + set: + error: $error + export: + as: '$context + { error: $error }' + - buyPet: + if: $context.error == null + call: http + with: + method: put + endpoint: https://petstore.swagger.io/v2/pet/{petId} + body: '${ . + { status: "sold" } }' \ No newline at end of file diff --git a/pom.xml b/pom.xml index 88091a841..2768fcc4a 100644 --- a/pom.xml +++ b/pom.xml @@ -46,6 +46,7 @@ examples experimental fluent + mermaid From 31f4d850e2f35c99c60c6d1d3cd76eea3bd856af Mon Sep 17 00:00:00 2001 From: Ricardo Zanini Date: Fri, 29 Aug 2025 19:56:44 -0400 Subject: [PATCH 2/3] Add documentation, tests, wrap up nodes Signed-off-by: Ricardo Zanini --- impl/test/pom.xml | 131 ++++++------ .../impl/test/EventDefinitionTest.java | 28 ++- .../impl/test/HTTPWorkflowDefinitionTest.java | 20 +- .../impl/test/LifeCycleEventsTest.java | 19 +- .../test/OAuthHTTPWorkflowDefinitionTest.java | 42 ++-- .../impl/test/WorkflowDefinitionTest.java | 29 +-- .../call-http-endpoint-interpolation.yaml | 0 ...http-query-parameters-external-schema.yaml | 0 .../call-http-query-parameters.yaml | 0 .../{ => workflows-samples}/callGetHttp.yaml | 0 .../{ => workflows-samples}/callPostHttp.yaml | 0 .../conditional-set.yaml | 0 .../{ => workflows-samples}/emit-doctor.yaml | 0 .../{ => workflows-samples}/emit-out.yaml | 0 .../{ => workflows-samples}/emit.yaml | 0 .../{ => workflows-samples}/for-collect.yaml | 0 .../{ => workflows-samples}/for-sum.yaml | 0 .../fork-no-compete.yaml | 0 .../{ => workflows-samples}/fork.yaml | 0 .../listen-to-all.yaml | 0 .../listen-to-any-filter.yaml | 0 .../listen-to-any-until-consumed.yaml | 0 .../listen-to-any-until.yaml | 0 .../listen-to-any.yaml | 0 ...ntSecretPostClientCredentialsHttpCall.yaml | 0 ...etPostClientCredentialsParamsHttpCall.yaml | 0 ...ntCredentialsParamsNoEndPointHttpCall.yaml | 0 ...ntSecretPostPasswordAllGrantsHttpCall.yaml | 0 ...ClientSecretPostPasswordAsArgHttpCall.yaml | 0 ...oAuthClientSecretPostPasswordHttpCall.yaml | 0 ...SecretPostPasswordNoEndpointsHttpCall.yaml | 0 .../oAuthJSONClientCredentialsHttpCall.yaml | 0 ...thJSONClientCredentialsParamsHttpCall.yaml | 0 ...ntCredentialsParamsNoEndPointHttpCall.yaml | 0 .../oAuthJSONPasswordAllGrantsHttpCall.yaml | 0 .../oAuthJSONPasswordAsArgHttpCall.yaml | 0 .../oAuthJSONPasswordHttpCall.yaml | 0 .../oAuthJSONPasswordNoEndpointsHttpCall.yaml | 0 .../{ => workflows-samples}/raise-inline.yaml | 0 .../raise-reusable.yaml | 0 .../simple-expression.yaml | 0 .../switch-then-loop.yaml | 0 .../switch-then-string.yaml | 0 .../{ => workflows-samples}/wait-set.yaml | 0 mermaid/README.md | 193 +++++++++++++++++ mermaid/pom.xml | 17 ++ .../serverlessworkflow/mermaid/CallNode.java | 78 +++++++ .../mermaid/DefaultNodeRenderer.java | 18 +- .../io/serverlessworkflow/mermaid/Edge.java | 103 ++++++++++ .../mermaid/EndpointStringify.java | 194 ++++++++++++++++++ .../serverlessworkflow/mermaid/ForNode.java | 12 +- .../serverlessworkflow/mermaid/ForkNode.java | 9 +- .../io/serverlessworkflow/mermaid/Ids.java | 50 ++++- .../mermaid/IteratorNode.java | 12 +- .../mermaid/ListenNode.java | 15 +- .../serverlessworkflow/mermaid/Mermaid.java | 1 + .../mermaid/MermaidGraph.java | 42 ++-- .../mermaid/MermaidInk.java | 71 +++++++ .../io/serverlessworkflow/mermaid/Node.java | 44 ++-- .../mermaid/NodeBuilder.java | 49 +++-- .../mermaid/NodeRenderer.java | 6 +- .../serverlessworkflow/mermaid/NodeType.java | 5 + ...{SplitNodeRenderer.java => RaiseNode.java} | 24 +-- .../serverlessworkflow/mermaid/RunNode.java | 50 +++++ .../serverlessworkflow/mermaid/SplitNode.java | 63 ------ .../mermaid/SubgraphNodeRenderer.java | 4 +- .../mermaid/SwitchNode.java | 56 +++++ .../serverlessworkflow/mermaid/TaskNode.java | 43 +--- .../mermaid/TryBlockNode.java | 24 --- .../mermaid/TryCatchNode.java | 2 +- .../serverlessworkflow/mermaid/WaitNode.java | 107 ++++++++++ .../mermaid/ClasspathYamlFinder.java | 127 ++++++++++++ .../mermaid/MermaidSmokeTest.java | 51 +++++ .../mermaid/MermaidTest.java | 31 --- .../call-http-endpoint-interpolation.yaml | 13 -- .../src/test/resources/samples/composite.yaml | 17 -- mermaid/src/test/resources/samples/emit.yaml | 19 -- mermaid/src/test/resources/samples/fork.yaml | 26 --- .../listen-to-any-forever-foreach.yaml | 22 -- .../src/test/resources/samples/pet-store.yaml | 88 -------- .../test/resources/samples/room-readings.yaml | 44 ---- .../src/test/resources/samples/try-catch.yaml | 39 ---- 82 files changed, 1403 insertions(+), 635 deletions(-) rename impl/test/src/test/resources/{ => workflows-samples}/call-http-endpoint-interpolation.yaml (100%) rename impl/test/src/test/resources/{ => workflows-samples}/call-http-query-parameters-external-schema.yaml (100%) rename impl/test/src/test/resources/{ => workflows-samples}/call-http-query-parameters.yaml (100%) rename impl/test/src/test/resources/{ => workflows-samples}/callGetHttp.yaml (100%) rename impl/test/src/test/resources/{ => workflows-samples}/callPostHttp.yaml (100%) rename impl/test/src/test/resources/{ => workflows-samples}/conditional-set.yaml (100%) rename impl/test/src/test/resources/{ => workflows-samples}/emit-doctor.yaml (100%) rename impl/test/src/test/resources/{ => workflows-samples}/emit-out.yaml (100%) rename impl/test/src/test/resources/{ => workflows-samples}/emit.yaml (100%) rename impl/test/src/test/resources/{ => workflows-samples}/for-collect.yaml (100%) rename impl/test/src/test/resources/{ => workflows-samples}/for-sum.yaml (100%) rename impl/test/src/test/resources/{ => workflows-samples}/fork-no-compete.yaml (100%) rename impl/test/src/test/resources/{ => workflows-samples}/fork.yaml (100%) rename impl/test/src/test/resources/{ => workflows-samples}/listen-to-all.yaml (100%) rename impl/test/src/test/resources/{ => workflows-samples}/listen-to-any-filter.yaml (100%) rename impl/test/src/test/resources/{ => workflows-samples}/listen-to-any-until-consumed.yaml (100%) rename impl/test/src/test/resources/{ => workflows-samples}/listen-to-any-until.yaml (100%) rename impl/test/src/test/resources/{ => workflows-samples}/listen-to-any.yaml (100%) rename impl/test/src/test/resources/{ => workflows-samples}/oAuthClientSecretPostClientCredentialsHttpCall.yaml (100%) rename impl/test/src/test/resources/{ => workflows-samples}/oAuthClientSecretPostClientCredentialsParamsHttpCall.yaml (100%) rename impl/test/src/test/resources/{ => workflows-samples}/oAuthClientSecretPostClientCredentialsParamsNoEndPointHttpCall.yaml (100%) rename impl/test/src/test/resources/{ => workflows-samples}/oAuthClientSecretPostPasswordAllGrantsHttpCall.yaml (100%) rename impl/test/src/test/resources/{ => workflows-samples}/oAuthClientSecretPostPasswordAsArgHttpCall.yaml (100%) rename impl/test/src/test/resources/{ => workflows-samples}/oAuthClientSecretPostPasswordHttpCall.yaml (100%) rename impl/test/src/test/resources/{ => workflows-samples}/oAuthClientSecretPostPasswordNoEndpointsHttpCall.yaml (100%) rename impl/test/src/test/resources/{ => workflows-samples}/oAuthJSONClientCredentialsHttpCall.yaml (100%) rename impl/test/src/test/resources/{ => workflows-samples}/oAuthJSONClientCredentialsParamsHttpCall.yaml (100%) rename impl/test/src/test/resources/{ => workflows-samples}/oAuthJSONClientCredentialsParamsNoEndPointHttpCall.yaml (100%) rename impl/test/src/test/resources/{ => workflows-samples}/oAuthJSONPasswordAllGrantsHttpCall.yaml (100%) rename impl/test/src/test/resources/{ => workflows-samples}/oAuthJSONPasswordAsArgHttpCall.yaml (100%) rename impl/test/src/test/resources/{ => workflows-samples}/oAuthJSONPasswordHttpCall.yaml (100%) rename impl/test/src/test/resources/{ => workflows-samples}/oAuthJSONPasswordNoEndpointsHttpCall.yaml (100%) rename impl/test/src/test/resources/{ => workflows-samples}/raise-inline.yaml (100%) rename impl/test/src/test/resources/{ => workflows-samples}/raise-reusable.yaml (100%) rename impl/test/src/test/resources/{ => workflows-samples}/simple-expression.yaml (100%) rename impl/test/src/test/resources/{ => workflows-samples}/switch-then-loop.yaml (100%) rename impl/test/src/test/resources/{ => workflows-samples}/switch-then-string.yaml (100%) rename impl/test/src/test/resources/{ => workflows-samples}/wait-set.yaml (100%) create mode 100644 mermaid/README.md create mode 100644 mermaid/src/main/java/io/serverlessworkflow/mermaid/CallNode.java create mode 100644 mermaid/src/main/java/io/serverlessworkflow/mermaid/Edge.java create mode 100644 mermaid/src/main/java/io/serverlessworkflow/mermaid/EndpointStringify.java create mode 100644 mermaid/src/main/java/io/serverlessworkflow/mermaid/MermaidInk.java rename mermaid/src/main/java/io/serverlessworkflow/mermaid/{SplitNodeRenderer.java => RaiseNode.java} (60%) create mode 100644 mermaid/src/main/java/io/serverlessworkflow/mermaid/RunNode.java delete mode 100644 mermaid/src/main/java/io/serverlessworkflow/mermaid/SplitNode.java create mode 100644 mermaid/src/main/java/io/serverlessworkflow/mermaid/SwitchNode.java delete mode 100644 mermaid/src/main/java/io/serverlessworkflow/mermaid/TryBlockNode.java create mode 100644 mermaid/src/main/java/io/serverlessworkflow/mermaid/WaitNode.java create mode 100644 mermaid/src/test/java/io/serverlessworkflow/mermaid/ClasspathYamlFinder.java create mode 100644 mermaid/src/test/java/io/serverlessworkflow/mermaid/MermaidSmokeTest.java delete mode 100644 mermaid/src/test/java/io/serverlessworkflow/mermaid/MermaidTest.java delete mode 100644 mermaid/src/test/resources/samples/call-http-endpoint-interpolation.yaml delete mode 100644 mermaid/src/test/resources/samples/composite.yaml delete mode 100644 mermaid/src/test/resources/samples/emit.yaml delete mode 100644 mermaid/src/test/resources/samples/fork.yaml delete mode 100644 mermaid/src/test/resources/samples/listen-to-any-forever-foreach.yaml delete mode 100644 mermaid/src/test/resources/samples/pet-store.yaml delete mode 100644 mermaid/src/test/resources/samples/room-readings.yaml delete mode 100644 mermaid/src/test/resources/samples/try-catch.yaml diff --git a/impl/test/pom.xml b/impl/test/pom.xml index 33a81db58..e9e24013a 100644 --- a/impl/test/pom.xml +++ b/impl/test/pom.xml @@ -1,60 +1,75 @@ - - 4.0.0 - - io.serverlessworkflow - serverlessworkflow-impl - 8.0.0-SNAPSHOT - - serverlessworkflow-impl-test - Serverless Workflow :: Impl :: Test - - + + 4.0.0 + io.serverlessworkflow - serverlessworkflow-impl-jackson - - - io.serverlessworkflow - serverlessworkflow-api - - - io.serverlessworkflow - serverlessworkflow-impl-http - - - io.serverlessworkflow - serverlessworkflow-impl-jackson-jwt - - - org.glassfish.jersey.media - jersey-media-json-jackson - - - org.glassfish.jersey.core - jersey-client - - - org.junit.jupiter - junit-jupiter-engine - - - org.junit.jupiter - junit-jupiter-params - - - org.assertj - assertj-core - - - ch.qos.logback - logback-classic - - - org.mockito - mockito-core - - - com.squareup.okhttp3 - mockwebserver - - + serverlessworkflow-impl + 8.0.0-SNAPSHOT + + serverlessworkflow-impl-test + Serverless Workflow :: Impl :: Test + + + io.serverlessworkflow + serverlessworkflow-impl-jackson + + + io.serverlessworkflow + serverlessworkflow-api + + + io.serverlessworkflow + serverlessworkflow-impl-http + + + io.serverlessworkflow + serverlessworkflow-impl-jackson-jwt + + + org.glassfish.jersey.media + jersey-media-json-jackson + + + org.glassfish.jersey.core + jersey-client + + + org.junit.jupiter + junit-jupiter-engine + + + org.junit.jupiter + junit-jupiter-params + + + org.assertj + assertj-core + + + ch.qos.logback + logback-classic + + + org.mockito + mockito-core + + + com.squareup.okhttp3 + mockwebserver + + + + + + maven-jar-plugin + + + + test-jar + + + + + + \ No newline at end of file diff --git a/impl/test/src/test/java/io/serverlessworkflow/impl/test/EventDefinitionTest.java b/impl/test/src/test/java/io/serverlessworkflow/impl/test/EventDefinitionTest.java index d2ed44072..9be637373 100644 --- a/impl/test/src/test/java/io/serverlessworkflow/impl/test/EventDefinitionTest.java +++ b/impl/test/src/test/java/io/serverlessworkflow/impl/test/EventDefinitionTest.java @@ -93,9 +93,10 @@ void testEventsListened(String listen, String emit1, String emit2, JsonNode expe void testForEachInAnyIsExecutedAsEventArrive() throws IOException, InterruptedException { WorkflowDefinition listenDefinition = appl.workflowDefinition( - WorkflowReader.readWorkflowFromClasspath("listen-to-any-until.yaml")); + WorkflowReader.readWorkflowFromClasspath("workflows-samples/listen-to-any-until.yaml")); WorkflowDefinition emitDoctorDefinition = - appl.workflowDefinition(WorkflowReader.readWorkflowFromClasspath("emit-doctor.yaml")); + appl.workflowDefinition( + WorkflowReader.readWorkflowFromClasspath("workflows-samples/emit-doctor.yaml")); WorkflowInstance waitingInstance = listenDefinition.instance(Map.of()); CompletableFuture future = waitingInstance.start(); assertThat(waitingInstance.status()).isEqualTo(WorkflowStatus.WAITING); @@ -116,22 +117,29 @@ private static Instant getInstant(ArrayNode result, int index) { private static Stream eventListenerParameters() { return Stream.of( - Arguments.of("listen-to-any.yaml", "emit.yaml", array(cruellaDeVil()), Map.of()), Arguments.of( - "listen-to-any-filter.yaml", "emit-doctor.yaml", doctor(), Map.of("temperature", 39))); + "workflows-samples/listen-to-any.yaml", + "workflows-samples/emit.yaml", + array(cruellaDeVil()), + Map.of()), + Arguments.of( + "workflows-samples/listen-to-any-filter.yaml", + "workflows-samples/emit-doctor.yaml", + doctor(), + Map.of("temperature", 39))); } private static Stream eventsListenerParameters() { return Stream.of( Arguments.of( - "listen-to-all.yaml", - "emit-doctor.yaml", - "emit.yaml", + "workflows-samples/listen-to-all.yaml", + "workflows-samples/emit-doctor.yaml", + "workflows-samples/emit.yaml", array(temperature(), cruellaDeVil())), Arguments.of( - "listen-to-any-until-consumed.yaml", - "emit-doctor.yaml", - "emit-out.yaml", + "workflows-samples/listen-to-any-until-consumed.yaml", + "workflows-samples/emit-doctor.yaml", + "workflows-samples/emit-out.yaml", array(temperature()))); } diff --git a/impl/test/src/test/java/io/serverlessworkflow/impl/test/HTTPWorkflowDefinitionTest.java b/impl/test/src/test/java/io/serverlessworkflow/impl/test/HTTPWorkflowDefinitionTest.java index 84e95e1ef..35b1985fd 100644 --- a/impl/test/src/test/java/io/serverlessworkflow/impl/test/HTTPWorkflowDefinitionTest.java +++ b/impl/test/src/test/java/io/serverlessworkflow/impl/test/HTTPWorkflowDefinitionTest.java @@ -55,8 +55,8 @@ void testWorkflowExecution(String fileName, Object input, Condition cond @ParameterizedTest @ValueSource( strings = { - "call-http-query-parameters.yaml", - "call-http-query-parameters-external-schema.yaml" + "workflows-samples/call-http-query-parameters.yaml", + "workflows-samples/call-http-query-parameters-external-schema.yaml" }) void testWrongSchema(String fileName) { IllegalArgumentException exception = @@ -86,18 +86,22 @@ private static Stream provideParameters() { .equals("Star Trek"), "StartTrek"); return Stream.of( - Arguments.of("callGetHttp.yaml", petInput, petCondition), + Arguments.of("workflows-samples/callGetHttp.yaml", petInput, petCondition), Arguments.of( - "callGetHttp.yaml", + "workflows-samples/callGetHttp.yaml", Map.of("petId", "-1"), new Condition( o -> o.asMap().orElseThrow().containsKey("petId"), "notFoundCondition")), - Arguments.of("call-http-endpoint-interpolation.yaml", petInput, petCondition), - Arguments.of("call-http-query-parameters.yaml", starTrekInput, starTrekCondition), Arguments.of( - "call-http-query-parameters-external-schema.yaml", starTrekInput, starTrekCondition), + "workflows-samples/call-http-endpoint-interpolation.yaml", petInput, petCondition), Arguments.of( - "callPostHttp.yaml", + "workflows-samples/call-http-query-parameters.yaml", starTrekInput, starTrekCondition), + Arguments.of( + "workflows-samples/call-http-query-parameters-external-schema.yaml", + starTrekInput, + starTrekCondition), + Arguments.of( + "workflows-samples/callPostHttp.yaml", Map.of("name", "Javierito", "surname", "Unknown"), new Condition( o -> o.asText().orElseThrow().equals("Javierito"), "CallHttpPostCondition"))); diff --git a/impl/test/src/test/java/io/serverlessworkflow/impl/test/LifeCycleEventsTest.java b/impl/test/src/test/java/io/serverlessworkflow/impl/test/LifeCycleEventsTest.java index cc908b1fc..a8bf17bb0 100644 --- a/impl/test/src/test/java/io/serverlessworkflow/impl/test/LifeCycleEventsTest.java +++ b/impl/test/src/test/java/io/serverlessworkflow/impl/test/LifeCycleEventsTest.java @@ -82,7 +82,9 @@ void close() { void simpleWorkflow() throws IOException { WorkflowModel model = - appl.workflowDefinition(WorkflowReader.readWorkflowFromClasspath("simple-expression.yaml")) + appl.workflowDefinition( + WorkflowReader.readWorkflowFromClasspath( + "workflows-samples/simple-expression.yaml")) .instance(Map.of()) .start() .join(); @@ -109,7 +111,8 @@ void simpleWorkflow() throws IOException { void testSuspendResumeNotWait() throws IOException, ExecutionException, InterruptedException, TimeoutException { WorkflowInstance instance = - appl.workflowDefinition(WorkflowReader.readWorkflowFromClasspath("wait-set.yaml")) + appl.workflowDefinition( + WorkflowReader.readWorkflowFromClasspath("workflows-samples/wait-set.yaml")) .instance(Map.of()); CompletableFuture future = instance.start(); instance.suspend(); @@ -131,7 +134,8 @@ void testSuspendResumeNotWait() void testSuspendResumeWait() throws IOException, ExecutionException, InterruptedException, TimeoutException { WorkflowInstance instance = - appl.workflowDefinition(WorkflowReader.readWorkflowFromClasspath("wait-set.yaml")) + appl.workflowDefinition( + WorkflowReader.readWorkflowFromClasspath("workflows-samples/wait-set.yaml")) .instance(Map.of()); CompletableFuture future = instance.start(); assertThat(instance.status()).isEqualTo(WorkflowStatus.WAITING); @@ -158,7 +162,8 @@ void testSuspendResumeWait() @Test void testCancel() throws IOException, InterruptedException { WorkflowInstance instance = - appl.workflowDefinition(WorkflowReader.readWorkflowFromClasspath("wait-set.yaml")) + appl.workflowDefinition( + WorkflowReader.readWorkflowFromClasspath("workflows-samples/wait-set.yaml")) .instance(Map.of()); CompletableFuture future = instance.start(); instance.cancel(); @@ -178,7 +183,8 @@ void testCancel() throws IOException, InterruptedException { void testSuspendResumeTimeout() throws IOException, ExecutionException, InterruptedException, TimeoutException { WorkflowInstance instance = - appl.workflowDefinition(WorkflowReader.readWorkflowFromClasspath("wait-set.yaml")) + appl.workflowDefinition( + WorkflowReader.readWorkflowFromClasspath("workflows-samples/wait-set.yaml")) .instance(Map.of()); CompletableFuture future = instance.start(); instance.suspend(); @@ -188,7 +194,8 @@ void testSuspendResumeTimeout() @Test void testError() throws IOException { - Workflow workflow = WorkflowReader.readWorkflowFromClasspath("raise-inline.yaml"); + Workflow workflow = + WorkflowReader.readWorkflowFromClasspath("workflows-samples/raise-inline.yaml"); assertThat( catchThrowableOfType( CompletionException.class, diff --git a/impl/test/src/test/java/io/serverlessworkflow/impl/test/OAuthHTTPWorkflowDefinitionTest.java b/impl/test/src/test/java/io/serverlessworkflow/impl/test/OAuthHTTPWorkflowDefinitionTest.java index b218fb25f..157b894e0 100644 --- a/impl/test/src/test/java/io/serverlessworkflow/impl/test/OAuthHTTPWorkflowDefinitionTest.java +++ b/impl/test/src/test/java/io/serverlessworkflow/impl/test/OAuthHTTPWorkflowDefinitionTest.java @@ -98,7 +98,8 @@ public void testOAuthClientSecretPostPasswordWorkflowExecution() throws Exceptio .setHeader("Content-Type", "application/json") .setResponseCode(200)); - Workflow workflow = readWorkflowFromClasspath("oAuthClientSecretPostPasswordHttpCall.yaml"); + Workflow workflow = + readWorkflowFromClasspath("workflows-samples/oAuthClientSecretPostPasswordHttpCall.yaml"); Map result; try (WorkflowApplication app = WorkflowApplication.builder().build()) { result = @@ -142,7 +143,8 @@ public void testOAuthClientSecretPostWithArgsWorkflowExecution() throws Exceptio .setResponseCode(200)); Workflow workflow = - readWorkflowFromClasspath("oAuthClientSecretPostPasswordAsArgHttpCall.yaml"); + readWorkflowFromClasspath( + "workflows-samples/oAuthClientSecretPostPasswordAsArgHttpCall.yaml"); Map result; Map params = Map.of( @@ -193,7 +195,8 @@ public void testOAuthClientSecretPostWithArgsNoEndPointWorkflowExecution() throw .setResponseCode(200)); Workflow workflow = - readWorkflowFromClasspath("oAuthClientSecretPostPasswordNoEndpointsHttpCall.yaml"); + readWorkflowFromClasspath( + "workflows-samples/oAuthClientSecretPostPasswordNoEndpointsHttpCall.yaml"); Map result; Map params = Map.of( @@ -244,7 +247,8 @@ public void testOAuthClientSecretPostWithArgsAllGrantsWorkflowExecution() throws .setResponseCode(200)); Workflow workflow = - readWorkflowFromClasspath("oAuthClientSecretPostPasswordAllGrantsHttpCall.yaml"); + readWorkflowFromClasspath( + "workflows-samples/oAuthClientSecretPostPasswordAllGrantsHttpCall.yaml"); Map result; Map params = Map.of( @@ -303,7 +307,8 @@ public void testOAuthClientSecretPostClientCredentialsWorkflowExecution() throws .setResponseCode(200)); Workflow workflow = - readWorkflowFromClasspath("oAuthClientSecretPostClientCredentialsHttpCall.yaml"); + readWorkflowFromClasspath( + "workflows-samples/oAuthClientSecretPostClientCredentialsHttpCall.yaml"); Map result; try (WorkflowApplication app = WorkflowApplication.builder().build()) { result = @@ -348,7 +353,8 @@ public void testOAuthClientSecretPostClientCredentialsParamsWorkflowExecution() .setResponseCode(200)); Workflow workflow = - readWorkflowFromClasspath("oAuthClientSecretPostClientCredentialsParamsHttpCall.yaml"); + readWorkflowFromClasspath( + "workflows-samples/oAuthClientSecretPostClientCredentialsParamsHttpCall.yaml"); Map result; Map params = Map.of( @@ -400,7 +406,7 @@ public void testOAuthClientSecretPostClientCredentialsParamsNoEndpointWorkflowEx Workflow workflow = readWorkflowFromClasspath( - "oAuthClientSecretPostClientCredentialsParamsNoEndPointHttpCall.yaml"); + "workflows-samples/oAuthClientSecretPostClientCredentialsParamsNoEndPointHttpCall.yaml"); Map result; Map params = Map.of( @@ -448,7 +454,8 @@ public void testOAuthJSONPasswordWorkflowExecution() throws Exception { .setHeader("Content-Type", "application/json") .setResponseCode(200)); - Workflow workflow = readWorkflowFromClasspath("oAuthJSONPasswordHttpCall.yaml"); + Workflow workflow = + readWorkflowFromClasspath("workflows-samples/oAuthJSONPasswordHttpCall.yaml"); Map result; try (WorkflowApplication app = WorkflowApplication.builder().build()) { result = @@ -505,7 +512,8 @@ public void testOAuthJSONWithArgsWorkflowExecution() throws Exception { .setHeader("Content-Type", "application/json") .setResponseCode(200)); - Workflow workflow = readWorkflowFromClasspath("oAuthJSONPasswordAsArgHttpCall.yaml"); + Workflow workflow = + readWorkflowFromClasspath("workflows-samples/oAuthJSONPasswordAsArgHttpCall.yaml"); Map result; Map params = Map.of( @@ -566,7 +574,8 @@ public void testOAuthJSONWithArgsNoEndPointWorkflowExecution() throws Exception .setHeader("Content-Type", "application/json") .setResponseCode(200)); - Workflow workflow = readWorkflowFromClasspath("oAuthJSONPasswordNoEndpointsHttpCall.yaml"); + Workflow workflow = + readWorkflowFromClasspath("workflows-samples/oAuthJSONPasswordNoEndpointsHttpCall.yaml"); Map result; Map params = Map.of( @@ -626,7 +635,8 @@ public void testOAuthJSONWithArgsAllGrantsWorkflowExecution() throws Exception { .setHeader("Content-Type", "application/json") .setResponseCode(200)); - Workflow workflow = readWorkflowFromClasspath("oAuthJSONPasswordAllGrantsHttpCall.yaml"); + Workflow workflow = + readWorkflowFromClasspath("workflows-samples/oAuthJSONPasswordAllGrantsHttpCall.yaml"); Map result; Map params = Map.of( @@ -700,7 +710,8 @@ public void testOAuthJSONClientCredentialsWorkflowExecution() throws Exception { .setHeader("Content-Type", "application/json") .setResponseCode(200)); - Workflow workflow = readWorkflowFromClasspath("oAuthJSONClientCredentialsHttpCall.yaml"); + Workflow workflow = + readWorkflowFromClasspath("workflows-samples/oAuthJSONClientCredentialsHttpCall.yaml"); Map result; try (WorkflowApplication app = WorkflowApplication.builder().build()) { result = @@ -749,7 +760,9 @@ public void testOAuthJSONClientCredentialsParamsWorkflowExecution() throws Excep .setHeader("Content-Type", "application/json") .setResponseCode(200)); - Workflow workflow = readWorkflowFromClasspath("oAuthJSONClientCredentialsParamsHttpCall.yaml"); + Workflow workflow = + readWorkflowFromClasspath( + "workflows-samples/oAuthJSONClientCredentialsParamsHttpCall.yaml"); Map result; Map params = Map.of( @@ -804,7 +817,8 @@ public void testOAuthJSONClientCredentialsParamsNoEndpointWorkflowExecution() th .setResponseCode(200)); Workflow workflow = - readWorkflowFromClasspath("oAuthJSONClientCredentialsParamsNoEndPointHttpCall.yaml"); + readWorkflowFromClasspath( + "workflows-samples/oAuthJSONClientCredentialsParamsNoEndPointHttpCall.yaml"); Map result; Map params = Map.of( diff --git a/impl/test/src/test/java/io/serverlessworkflow/impl/test/WorkflowDefinitionTest.java b/impl/test/src/test/java/io/serverlessworkflow/impl/test/WorkflowDefinitionTest.java index 0ee27c581..d47dd4c0b 100644 --- a/impl/test/src/test/java/io/serverlessworkflow/impl/test/WorkflowDefinitionTest.java +++ b/impl/test/src/test/java/io/serverlessworkflow/impl/test/WorkflowDefinitionTest.java @@ -60,56 +60,59 @@ void testWorkflowExecution(String fileName, Consumer asserti private static Stream provideParameters() { return Stream.of( args( - "switch-then-string.yaml", + "workflows-samples/switch-then-string.yaml", Map.of("orderType", "electronic"), o -> assertThat(o).isEqualTo(Map.of("validate", true, "status", "fulfilled"))), args( - "switch-then-string.yaml", + "workflows-samples/switch-then-string.yaml", Map.of("orderType", "physical"), o -> assertThat(o) .isEqualTo(Map.of("inventory", "clear", "items", 1, "address", "Elmer St"))), args( - "switch-then-string.yaml", + "workflows-samples/switch-then-string.yaml", Map.of("orderType", "unknown"), o -> assertThat(o).isEqualTo(Map.of("log", "warn", "message", "something's wrong"))), args( - "for-sum.yaml", + "workflows-samples/for-sum.yaml", Map.of("input", Arrays.asList(1, 2, 3)), o -> assertThat(o).isEqualTo(6)), args( - "switch-then-loop.yaml", + "workflows-samples/switch-then-loop.yaml", Map.of("count", 1), o -> assertThat(o).isEqualTo(Map.of("count", 6))), args( - "for-collect.yaml", + "workflows-samples/for-collect.yaml", Map.of("input", Arrays.asList(1, 2, 3)), o -> assertThat(o).isEqualTo(Map.of("output", Arrays.asList(2, 4, 6)))), args( - "simple-expression.yaml", + "workflows-samples/simple-expression.yaml", Map.of("input", Arrays.asList(1, 2, 3)), WorkflowDefinitionTest::checkSpecialKeywords), args( - "conditional-set.yaml", + "workflows-samples/conditional-set.yaml", Map.of("enabled", true), WorkflowDefinitionTest::checkEnableCondition), args( - "conditional-set.yaml", + "workflows-samples/conditional-set.yaml", Map.of("enabled", false), WorkflowDefinitionTest::checkDisableCondition), args( - "raise-inline.yaml", + "workflows-samples/raise-inline.yaml", WorkflowDefinitionTest::checkWorkflowException, WorkflowException.class), args( - "raise-reusable.yaml", + "workflows-samples/raise-reusable.yaml", WorkflowDefinitionTest::checkWorkflowException, WorkflowException.class), args( - "fork.yaml", + "workflows-samples/fork.yaml", Map.of(), o -> assertThat(((Map) o).get("patientId")).isIn("John", "Smith")), - argsJson("fork-no-compete.yaml", Map.of(), WorkflowDefinitionTest::checkNotCompeteOuput)); + argsJson( + "workflows-samples/fork-no-compete.yaml", + Map.of(), + WorkflowDefinitionTest::checkNotCompeteOuput)); } private static Arguments args( diff --git a/impl/test/src/test/resources/call-http-endpoint-interpolation.yaml b/impl/test/src/test/resources/workflows-samples/call-http-endpoint-interpolation.yaml similarity index 100% rename from impl/test/src/test/resources/call-http-endpoint-interpolation.yaml rename to impl/test/src/test/resources/workflows-samples/call-http-endpoint-interpolation.yaml diff --git a/impl/test/src/test/resources/call-http-query-parameters-external-schema.yaml b/impl/test/src/test/resources/workflows-samples/call-http-query-parameters-external-schema.yaml similarity index 100% rename from impl/test/src/test/resources/call-http-query-parameters-external-schema.yaml rename to impl/test/src/test/resources/workflows-samples/call-http-query-parameters-external-schema.yaml diff --git a/impl/test/src/test/resources/call-http-query-parameters.yaml b/impl/test/src/test/resources/workflows-samples/call-http-query-parameters.yaml similarity index 100% rename from impl/test/src/test/resources/call-http-query-parameters.yaml rename to impl/test/src/test/resources/workflows-samples/call-http-query-parameters.yaml diff --git a/impl/test/src/test/resources/callGetHttp.yaml b/impl/test/src/test/resources/workflows-samples/callGetHttp.yaml similarity index 100% rename from impl/test/src/test/resources/callGetHttp.yaml rename to impl/test/src/test/resources/workflows-samples/callGetHttp.yaml diff --git a/impl/test/src/test/resources/callPostHttp.yaml b/impl/test/src/test/resources/workflows-samples/callPostHttp.yaml similarity index 100% rename from impl/test/src/test/resources/callPostHttp.yaml rename to impl/test/src/test/resources/workflows-samples/callPostHttp.yaml diff --git a/impl/test/src/test/resources/conditional-set.yaml b/impl/test/src/test/resources/workflows-samples/conditional-set.yaml similarity index 100% rename from impl/test/src/test/resources/conditional-set.yaml rename to impl/test/src/test/resources/workflows-samples/conditional-set.yaml diff --git a/impl/test/src/test/resources/emit-doctor.yaml b/impl/test/src/test/resources/workflows-samples/emit-doctor.yaml similarity index 100% rename from impl/test/src/test/resources/emit-doctor.yaml rename to impl/test/src/test/resources/workflows-samples/emit-doctor.yaml diff --git a/impl/test/src/test/resources/emit-out.yaml b/impl/test/src/test/resources/workflows-samples/emit-out.yaml similarity index 100% rename from impl/test/src/test/resources/emit-out.yaml rename to impl/test/src/test/resources/workflows-samples/emit-out.yaml diff --git a/impl/test/src/test/resources/emit.yaml b/impl/test/src/test/resources/workflows-samples/emit.yaml similarity index 100% rename from impl/test/src/test/resources/emit.yaml rename to impl/test/src/test/resources/workflows-samples/emit.yaml diff --git a/impl/test/src/test/resources/for-collect.yaml b/impl/test/src/test/resources/workflows-samples/for-collect.yaml similarity index 100% rename from impl/test/src/test/resources/for-collect.yaml rename to impl/test/src/test/resources/workflows-samples/for-collect.yaml diff --git a/impl/test/src/test/resources/for-sum.yaml b/impl/test/src/test/resources/workflows-samples/for-sum.yaml similarity index 100% rename from impl/test/src/test/resources/for-sum.yaml rename to impl/test/src/test/resources/workflows-samples/for-sum.yaml diff --git a/impl/test/src/test/resources/fork-no-compete.yaml b/impl/test/src/test/resources/workflows-samples/fork-no-compete.yaml similarity index 100% rename from impl/test/src/test/resources/fork-no-compete.yaml rename to impl/test/src/test/resources/workflows-samples/fork-no-compete.yaml diff --git a/impl/test/src/test/resources/fork.yaml b/impl/test/src/test/resources/workflows-samples/fork.yaml similarity index 100% rename from impl/test/src/test/resources/fork.yaml rename to impl/test/src/test/resources/workflows-samples/fork.yaml diff --git a/impl/test/src/test/resources/listen-to-all.yaml b/impl/test/src/test/resources/workflows-samples/listen-to-all.yaml similarity index 100% rename from impl/test/src/test/resources/listen-to-all.yaml rename to impl/test/src/test/resources/workflows-samples/listen-to-all.yaml diff --git a/impl/test/src/test/resources/listen-to-any-filter.yaml b/impl/test/src/test/resources/workflows-samples/listen-to-any-filter.yaml similarity index 100% rename from impl/test/src/test/resources/listen-to-any-filter.yaml rename to impl/test/src/test/resources/workflows-samples/listen-to-any-filter.yaml diff --git a/impl/test/src/test/resources/listen-to-any-until-consumed.yaml b/impl/test/src/test/resources/workflows-samples/listen-to-any-until-consumed.yaml similarity index 100% rename from impl/test/src/test/resources/listen-to-any-until-consumed.yaml rename to impl/test/src/test/resources/workflows-samples/listen-to-any-until-consumed.yaml diff --git a/impl/test/src/test/resources/listen-to-any-until.yaml b/impl/test/src/test/resources/workflows-samples/listen-to-any-until.yaml similarity index 100% rename from impl/test/src/test/resources/listen-to-any-until.yaml rename to impl/test/src/test/resources/workflows-samples/listen-to-any-until.yaml diff --git a/impl/test/src/test/resources/listen-to-any.yaml b/impl/test/src/test/resources/workflows-samples/listen-to-any.yaml similarity index 100% rename from impl/test/src/test/resources/listen-to-any.yaml rename to impl/test/src/test/resources/workflows-samples/listen-to-any.yaml diff --git a/impl/test/src/test/resources/oAuthClientSecretPostClientCredentialsHttpCall.yaml b/impl/test/src/test/resources/workflows-samples/oAuthClientSecretPostClientCredentialsHttpCall.yaml similarity index 100% rename from impl/test/src/test/resources/oAuthClientSecretPostClientCredentialsHttpCall.yaml rename to impl/test/src/test/resources/workflows-samples/oAuthClientSecretPostClientCredentialsHttpCall.yaml diff --git a/impl/test/src/test/resources/oAuthClientSecretPostClientCredentialsParamsHttpCall.yaml b/impl/test/src/test/resources/workflows-samples/oAuthClientSecretPostClientCredentialsParamsHttpCall.yaml similarity index 100% rename from impl/test/src/test/resources/oAuthClientSecretPostClientCredentialsParamsHttpCall.yaml rename to impl/test/src/test/resources/workflows-samples/oAuthClientSecretPostClientCredentialsParamsHttpCall.yaml diff --git a/impl/test/src/test/resources/oAuthClientSecretPostClientCredentialsParamsNoEndPointHttpCall.yaml b/impl/test/src/test/resources/workflows-samples/oAuthClientSecretPostClientCredentialsParamsNoEndPointHttpCall.yaml similarity index 100% rename from impl/test/src/test/resources/oAuthClientSecretPostClientCredentialsParamsNoEndPointHttpCall.yaml rename to impl/test/src/test/resources/workflows-samples/oAuthClientSecretPostClientCredentialsParamsNoEndPointHttpCall.yaml diff --git a/impl/test/src/test/resources/oAuthClientSecretPostPasswordAllGrantsHttpCall.yaml b/impl/test/src/test/resources/workflows-samples/oAuthClientSecretPostPasswordAllGrantsHttpCall.yaml similarity index 100% rename from impl/test/src/test/resources/oAuthClientSecretPostPasswordAllGrantsHttpCall.yaml rename to impl/test/src/test/resources/workflows-samples/oAuthClientSecretPostPasswordAllGrantsHttpCall.yaml diff --git a/impl/test/src/test/resources/oAuthClientSecretPostPasswordAsArgHttpCall.yaml b/impl/test/src/test/resources/workflows-samples/oAuthClientSecretPostPasswordAsArgHttpCall.yaml similarity index 100% rename from impl/test/src/test/resources/oAuthClientSecretPostPasswordAsArgHttpCall.yaml rename to impl/test/src/test/resources/workflows-samples/oAuthClientSecretPostPasswordAsArgHttpCall.yaml diff --git a/impl/test/src/test/resources/oAuthClientSecretPostPasswordHttpCall.yaml b/impl/test/src/test/resources/workflows-samples/oAuthClientSecretPostPasswordHttpCall.yaml similarity index 100% rename from impl/test/src/test/resources/oAuthClientSecretPostPasswordHttpCall.yaml rename to impl/test/src/test/resources/workflows-samples/oAuthClientSecretPostPasswordHttpCall.yaml diff --git a/impl/test/src/test/resources/oAuthClientSecretPostPasswordNoEndpointsHttpCall.yaml b/impl/test/src/test/resources/workflows-samples/oAuthClientSecretPostPasswordNoEndpointsHttpCall.yaml similarity index 100% rename from impl/test/src/test/resources/oAuthClientSecretPostPasswordNoEndpointsHttpCall.yaml rename to impl/test/src/test/resources/workflows-samples/oAuthClientSecretPostPasswordNoEndpointsHttpCall.yaml diff --git a/impl/test/src/test/resources/oAuthJSONClientCredentialsHttpCall.yaml b/impl/test/src/test/resources/workflows-samples/oAuthJSONClientCredentialsHttpCall.yaml similarity index 100% rename from impl/test/src/test/resources/oAuthJSONClientCredentialsHttpCall.yaml rename to impl/test/src/test/resources/workflows-samples/oAuthJSONClientCredentialsHttpCall.yaml diff --git a/impl/test/src/test/resources/oAuthJSONClientCredentialsParamsHttpCall.yaml b/impl/test/src/test/resources/workflows-samples/oAuthJSONClientCredentialsParamsHttpCall.yaml similarity index 100% rename from impl/test/src/test/resources/oAuthJSONClientCredentialsParamsHttpCall.yaml rename to impl/test/src/test/resources/workflows-samples/oAuthJSONClientCredentialsParamsHttpCall.yaml diff --git a/impl/test/src/test/resources/oAuthJSONClientCredentialsParamsNoEndPointHttpCall.yaml b/impl/test/src/test/resources/workflows-samples/oAuthJSONClientCredentialsParamsNoEndPointHttpCall.yaml similarity index 100% rename from impl/test/src/test/resources/oAuthJSONClientCredentialsParamsNoEndPointHttpCall.yaml rename to impl/test/src/test/resources/workflows-samples/oAuthJSONClientCredentialsParamsNoEndPointHttpCall.yaml diff --git a/impl/test/src/test/resources/oAuthJSONPasswordAllGrantsHttpCall.yaml b/impl/test/src/test/resources/workflows-samples/oAuthJSONPasswordAllGrantsHttpCall.yaml similarity index 100% rename from impl/test/src/test/resources/oAuthJSONPasswordAllGrantsHttpCall.yaml rename to impl/test/src/test/resources/workflows-samples/oAuthJSONPasswordAllGrantsHttpCall.yaml diff --git a/impl/test/src/test/resources/oAuthJSONPasswordAsArgHttpCall.yaml b/impl/test/src/test/resources/workflows-samples/oAuthJSONPasswordAsArgHttpCall.yaml similarity index 100% rename from impl/test/src/test/resources/oAuthJSONPasswordAsArgHttpCall.yaml rename to impl/test/src/test/resources/workflows-samples/oAuthJSONPasswordAsArgHttpCall.yaml diff --git a/impl/test/src/test/resources/oAuthJSONPasswordHttpCall.yaml b/impl/test/src/test/resources/workflows-samples/oAuthJSONPasswordHttpCall.yaml similarity index 100% rename from impl/test/src/test/resources/oAuthJSONPasswordHttpCall.yaml rename to impl/test/src/test/resources/workflows-samples/oAuthJSONPasswordHttpCall.yaml diff --git a/impl/test/src/test/resources/oAuthJSONPasswordNoEndpointsHttpCall.yaml b/impl/test/src/test/resources/workflows-samples/oAuthJSONPasswordNoEndpointsHttpCall.yaml similarity index 100% rename from impl/test/src/test/resources/oAuthJSONPasswordNoEndpointsHttpCall.yaml rename to impl/test/src/test/resources/workflows-samples/oAuthJSONPasswordNoEndpointsHttpCall.yaml diff --git a/impl/test/src/test/resources/raise-inline.yaml b/impl/test/src/test/resources/workflows-samples/raise-inline.yaml similarity index 100% rename from impl/test/src/test/resources/raise-inline.yaml rename to impl/test/src/test/resources/workflows-samples/raise-inline.yaml diff --git a/impl/test/src/test/resources/raise-reusable.yaml b/impl/test/src/test/resources/workflows-samples/raise-reusable.yaml similarity index 100% rename from impl/test/src/test/resources/raise-reusable.yaml rename to impl/test/src/test/resources/workflows-samples/raise-reusable.yaml diff --git a/impl/test/src/test/resources/simple-expression.yaml b/impl/test/src/test/resources/workflows-samples/simple-expression.yaml similarity index 100% rename from impl/test/src/test/resources/simple-expression.yaml rename to impl/test/src/test/resources/workflows-samples/simple-expression.yaml diff --git a/impl/test/src/test/resources/switch-then-loop.yaml b/impl/test/src/test/resources/workflows-samples/switch-then-loop.yaml similarity index 100% rename from impl/test/src/test/resources/switch-then-loop.yaml rename to impl/test/src/test/resources/workflows-samples/switch-then-loop.yaml diff --git a/impl/test/src/test/resources/switch-then-string.yaml b/impl/test/src/test/resources/workflows-samples/switch-then-string.yaml similarity index 100% rename from impl/test/src/test/resources/switch-then-string.yaml rename to impl/test/src/test/resources/workflows-samples/switch-then-string.yaml diff --git a/impl/test/src/test/resources/wait-set.yaml b/impl/test/src/test/resources/workflows-samples/wait-set.yaml similarity index 100% rename from impl/test/src/test/resources/wait-set.yaml rename to impl/test/src/test/resources/workflows-samples/wait-set.yaml diff --git a/mermaid/README.md b/mermaid/README.md new file mode 100644 index 000000000..e0f671ae3 --- /dev/null +++ b/mermaid/README.md @@ -0,0 +1,193 @@ +# serverlessworkflow-mermaid + +Generate **Mermaid** diagrams for [Serverless Workflow](https://serverlessworkflow.io/) definitions. +This library turns a `Workflow` into a Mermaid **flowchart**, with sensible shapes and wiring for common DSL constructs, and can optionally export **SVG/PNG** via a lightweight HTTP helper. + +--- + +## Features + +* **One-liner:** `new Mermaid().from(workflow)` → Mermaid string +* **Deterministic node IDs** (stable diffs / snapshots) +* **Start/End terminals** and distinct **Error terminal** for `raise` +* Supports: + + * `do` (sequences) + * `call`, `set`, `run`, `emit`, `wait`, `listen` + * `for` (loop subgraph with loopback) + * `try/catch` (nested subgraphs) + * `fork` (split/join with `ALL` or `ANY` depending on `compete`) + * `switch` (fan-out with labeled edges) + * `raise` (terminates at `__error`) +* Optional **image export** to SVG/PNG (no Node.js required) using `MermaidInk.render(...)` + +--- + +## Installation + +Add the dependency to the module where you want to render diagrams. + +
+Maven + +```xml + + io.serverlessworkflow + serverlessworkflow-mermaid + YOUR_VERSION + +``` + +
+ +
+Gradle (Kotlin) + +```kotlin +implementation("io.serverlessworkflow:serverlessworkflow-mermaid:YOUR_VERSION") +``` + +
+ +> This library depends on `serverlessworkflow-api` to read/construct workflows. + +--- + +## Quick start + +### 1) From a `Workflow` instance + +```java +import io.serverlessworkflow.api.types.Workflow; +import io.serverlessworkflow.mermaid.Mermaid; + +Workflow wf = /* build or load your workflow */; +String mermaid = new Mermaid().from(wf); +// paste into any Mermaid renderer/editor or export (see below) +System.out.println(mermaid); +``` + +### 2) From a YAML on the classpath + +```java +import io.serverlessworkflow.mermaid.Mermaid; + +String mermaid = new Mermaid().from("workflows/sample.yaml"); +``` + +The output includes a small config header and the `flowchart TD` graph: + +```mermaid +--- +config: + look: handDrawn + theme: base +--- +flowchart TD + n__start@{ shape: sm-circ, label: "__start" } --> n_process@{ shape: rect, label: "processOrder" } + ... + n__end@{ shape: stop, label: "__end" } +``` + +> The header is currently fixed; a future builder will make it customizable. + +--- + +## Export to SVG/PNG (optional) + +Use the built-in `MermaidInk` helper (HTTP call to mermaid.ink): + +```java +import java.nio.file.Path; +import io.serverlessworkflow.mermaid.Mermaid; +import io.serverlessworkflow.mermaid.MermaidInk; + +String mermaid = new Mermaid().from("workflows/sample.yaml"); + +// SVG +MermaidInk.render(mermaid, /*svg=*/true, Path.of("diagram.svg")); +// PNG +MermaidInk.render(mermaid, /*svg=*/false, Path.of("diagram.png")); +``` + +**Notes** + +* Requires network access to `https://mermaid.ink/`. +* Throws a `RuntimeException` on HTTP failure or file write issues. + +**Alternatives** + +* Run the official Mermaid CLI in a build step (`@mermaid-js/mermaid-cli`, Node). +* Use a diagram service such as Kroki (HTTP) if you prefer a self-hosted renderer. + +--- + +## Shapes & semantics (at a glance) + +* `n__start__` → small circle (`sm-circ`) +* `n__end__` → stop (`stop`) +* Simple tasks (`call`, `set`, `run`, `emit`, etc.) → `rect` (or a more specific shape where mapped) +* `for` → subgraph containing: + * a note with `each / in / at` + * a `loop` junction and a dashed `next` loopback (optional `while` label) +* `try/catch` → subgraph with `Try` and `Catch` nested subgraphs +* `fork` → subgraph with a split badge (`fork`) and a join badge labeled `ALL` or `ANY` +* `switch` → decision-like node with **labeled edges** for each case (including `default`) +* `raise` → distinct node that **terminates at `n__error__`** unless explicitly redirected + +All node labels are **escaped** for Mermaid (e.g., `[` `]` and line breaks), and IDs are **deterministic** (derived from task names/scope) so diagrams are stable across runs. + +--- + +## Example (switch + raise) + +**Workflow YAML** + +```yaml +do: + - processTicket: + switch: + - highPriority: + when: .ticket.priority == "high" + then: escalateToManager + - default: + then: raiseUndefinedPriorityError + - escalateToManager: + set: + status: escalated + then: exit + - raiseUndefinedPriorityError: + raise: + error: + type: https://fake/errors/undefined-priority + status: 400 +``` + +**Rendered (excerpt)** + +```mermaid +--- +config: + look: handDrawn + theme: base +--- +flowchart TD + n__start@{ shape: sm-circ, label: "__start" } --> n_sw@{ shape: diam, label: "processTicket" } + n_sw --|.ticket.priority == high| --> n_mgr@{ shape: rect, label: "escalateToManager" } + n_sw --|default| --> n_raise@{ shape: trap-b, label: "raiseUndefinedPriorityError" } + n_mgr --> n__end@{ shape: stop, label: "__end" } + n_raise --> n__error@{ shape: cross-circ, label: "__error" } +``` + +--- + +## FAQ + +**Can I change the look/theme?** +Not yet; the header uses `handDrawn` + `base`. A config builder is planned. You can always prepend your own header before rendering/export. + +**Are IDs stable?** +Yes. IDs are derived from task names (plus scope) with a short hash. Helper nodes (notes/loop/join) derive from the parent ID. + +**How do I add a custom task shape?** +Extend the existing `Node`/`NodeBuilder` pattern and map your class to a `NodeType`/shape. The graph builder is designed for specialized node subclasses. diff --git a/mermaid/pom.xml b/mermaid/pom.xml index 049c9b080..6f3746520 100644 --- a/mermaid/pom.xml +++ b/mermaid/pom.xml @@ -35,6 +35,16 @@ junit-jupiter-api test + + org.junit.jupiter + junit-jupiter-params + test + + + org.junit.jupiter + junit-jupiter-engine + test + org.mockito mockito-core @@ -45,6 +55,13 @@ assertj-core test + + io.serverlessworkflow + serverlessworkflow-impl-test + ${project.version} + tests + test + \ No newline at end of file diff --git a/mermaid/src/main/java/io/serverlessworkflow/mermaid/CallNode.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/CallNode.java new file mode 100644 index 000000000..c9a6bdbdb --- /dev/null +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/CallNode.java @@ -0,0 +1,78 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed 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 io.serverlessworkflow.mermaid; + +import io.serverlessworkflow.api.types.CallHTTP; +import io.serverlessworkflow.api.types.CallTask; +import io.serverlessworkflow.api.types.TaskItem; + +public class CallNode extends TaskNode { + + public CallNode(TaskItem task) { + super("", task, NodeType.RECT); + + if (task.getTask().getCallTask() == null) { + throw new IllegalArgumentException("Call task must contain an task"); + } + + StringBuilder label = new StringBuilder(); + CallTask callTask = task.getTask().getCallTask(); + if (callTask.getCallHTTP() != null) { + CallHTTP callHTTP = callTask.getCallHTTP(); + label + .append("call HTTP ") + .append(callHTTP.getWith().getMethod()) + .append(" ") + .append(EndpointStringify.of(callHTTP.getWith().getEndpoint())); + } else if (callTask.getCallFunction() != null) { + label.append("call function ").append(callTask.getCallFunction().getCall()); + } else if (callTask.getCallGRPC() != null) { + label + .append("call GRPC ") + .append(callTask.getCallGRPC().getWith().getService().getName()) + .append(" auth(") + .append( + EndpointStringify.summarizeAuth( + callTask.getCallGRPC().getWith().getService().getAuthentication())) + .append(")"); + } else if (callTask.getCallAsyncAPI() != null) { + label + .append("call Async API ") + .append(callTask.getCallAsyncAPI().getWith().getOperation()) + .append(" channel(") + .append(callTask.getCallAsyncAPI().getWith().getChannel()) + .append(") ") + .append(" auth(") + .append( + EndpointStringify.summarizeAuth( + callTask.getCallAsyncAPI().getWith().getAuthentication())) + .append(")"); + } else if (callTask.getCallOpenAPI() != null) { + label + .append("call OpenAPI ") + .append(callTask.getCallOpenAPI().getWith().getOperationId()) + .append(" auth(") + .append( + EndpointStringify.summarizeAuth( + callTask.getCallOpenAPI().getWith().getAuthentication())) + .append(")"); + } else { + label.append("call: ").append(task.getName()); + } + + this.label = label.toString(); + } +} diff --git a/mermaid/src/main/java/io/serverlessworkflow/mermaid/DefaultNodeRenderer.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/DefaultNodeRenderer.java index f5221e715..3bd71e79c 100644 --- a/mermaid/src/main/java/io/serverlessworkflow/mermaid/DefaultNodeRenderer.java +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/DefaultNodeRenderer.java @@ -18,7 +18,6 @@ public class DefaultNodeRenderer implements NodeRenderer { private final Node node; - protected String renderedArrow = "-->"; public DefaultNodeRenderer(Node node) { this.node = node; @@ -28,21 +27,16 @@ protected final Node getNode() { return node; } - @Override - public void setRenderedArrow(String renderedArrow) { - this.renderedArrow = renderedArrow; - } - public void render(StringBuilder sb, int level) { sb.append(ind(level)) .append(node.id) .append("@{ shape: ") .append(node.type.mermaidShape()) .append(", label: \"") - .append(NodeRenderer.escNodeLabel(node.label)) + .append(NodeRenderer.escLabel(node.label)) .append("\" }\n"); this.renderBody(sb, level); - this.renderNext(sb, level); + this.renderEdge(sb, level); } protected void renderBody(StringBuilder sb, int level) { @@ -51,12 +45,12 @@ protected void renderBody(StringBuilder sb, int level) { } } - protected void renderNext(StringBuilder sb, int level) { - if (node.getNext() != null) { + protected void renderEdge(StringBuilder sb, int level) { + for (Edge edge : this.getNode().getEdge()) { sb.append(ind(level)) .append(node.getId()) - .append(renderedArrow) - .append(node.getNext().getId()) + .append(edge.getArrow()) + .append(edge.getNodeId()) .append("\n"); } } diff --git a/mermaid/src/main/java/io/serverlessworkflow/mermaid/Edge.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/Edge.java new file mode 100644 index 000000000..7d86d4107 --- /dev/null +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/Edge.java @@ -0,0 +1,103 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed 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 io.serverlessworkflow.mermaid; + +import java.util.Objects; + +public class Edge { + + static final String ARROW_DEFAULT = "-->"; + static final String ARROW_DOTTED = "-.->"; + + private final String nodeId; + private final String taskName; + private String arrow; + + private Edge(String id, String taskName, String arrow) { + this.nodeId = id; + this.taskName = taskName; + this.arrow = arrow; + } + + public static Edge to(TaskNode node) { + return new Edge(node.getId(), node.getTask().getName(), ARROW_DEFAULT); + } + + public static Edge to(Node node) { + if (node instanceof TaskNode) { + return to((TaskNode) node); + } + return new Edge(node.getId(), node.getLabel(), ARROW_DEFAULT); + } + + public static Edge to(String taskName) { + return new Edge(Ids.of(taskName), taskName, ARROW_DEFAULT); + } + + public static Edge toEnd() { + return new Edge(MermaidGraph.END_NODE_ID, "", ARROW_DEFAULT); + } + + public Edge withArrow(String arrow) { + this.arrow = arrow; + return this; + } + + public String getArrow() { + return arrow; + } + + public void setArrow(String arrow) { + this.arrow = arrow; + } + + public String getNodeId() { + return nodeId; + } + + public String getTaskName() { + return taskName; + } + + @Override + public String toString() { + return "Edge{" + + "nodeId='" + + nodeId + + '\'' + + ", taskName='" + + taskName + + '\'' + + ", arrow='" + + arrow + + '\'' + + '}'; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + Edge edge = (Edge) o; + return Objects.equals(nodeId, edge.nodeId) + && Objects.equals(taskName, edge.taskName) + && Objects.equals(arrow, edge.arrow); + } + + @Override + public int hashCode() { + return Objects.hash(nodeId, taskName, arrow); + } +} diff --git a/mermaid/src/main/java/io/serverlessworkflow/mermaid/EndpointStringify.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/EndpointStringify.java new file mode 100644 index 000000000..dfd6bb485 --- /dev/null +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/EndpointStringify.java @@ -0,0 +1,194 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed 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 io.serverlessworkflow.mermaid; + +import io.serverlessworkflow.api.types.AuthenticationPolicy; +import io.serverlessworkflow.api.types.AuthenticationPolicyReference; +import io.serverlessworkflow.api.types.AuthenticationPolicyUnion; +import io.serverlessworkflow.api.types.Endpoint; +import io.serverlessworkflow.api.types.EndpointConfiguration; +import io.serverlessworkflow.api.types.EndpointUri; +import io.serverlessworkflow.api.types.ReferenceableAuthenticationPolicy; +import io.serverlessworkflow.api.types.UriTemplate; +import java.net.URI; + +public final class EndpointStringify { + + private EndpointStringify() {} + + /** Compact, human-friendly representation, always including auth info. */ + public static String of(Endpoint endpoint) { + if (endpoint == null) return "null (auth=none)"; + + // Determine the base (URI / template / expression) + String base = resolveBase(endpoint); + + // Determine auth (always appended) + String auth = "none"; + EndpointConfiguration cfg = endpoint.getEndpointConfiguration(); + if (cfg == null && endpoint.get() instanceof EndpointConfiguration) { + cfg = (EndpointConfiguration) endpoint.get(); + } + if (cfg != null) { + auth = summarizeAuth(cfg.getAuthentication()); + } + + return base + " (auth=" + auth + ")"; + } + + // ------------------- base rendering ------------------- + + private static String resolveBase(Endpoint endpoint) { + Object v = endpoint.get(); + if (v != null) { + if (v instanceof String) { + return stringifyRuntimeExpression((String) v); + } else if (v instanceof UriTemplate) { + return stringifyUriTemplate((UriTemplate) v); + } else if (v instanceof EndpointConfiguration) { + return stringifyEndpointConfiguration((EndpointConfiguration) v); + } + } + // Fallbacks if withXxx(...) used without @OneOfSetter + if (endpoint.getRuntimeExpression() != null) { + return stringifyRuntimeExpression(endpoint.getRuntimeExpression()); + } + if (endpoint.getUriTemplate() != null) { + return stringifyUriTemplate(endpoint.getUriTemplate()); + } + if (endpoint.getEndpointConfiguration() != null) { + return stringifyEndpointConfiguration(endpoint.getEndpointConfiguration()); + } + return "null"; + } + + private static String stringifyEndpointConfiguration(EndpointConfiguration cfg) { + if (cfg == null) return "null"; + return stringifyEndpointUri(cfg.getUri()); + } + + private static String stringifyEndpointUri(EndpointUri endpointUri) { + if (endpointUri == null) return "null"; + + Object v = endpointUri.get(); + if (v instanceof UriTemplate) { + return stringifyUriTemplate((UriTemplate) v); + } else if (v instanceof String) { + return stringifyRuntimeExpression((String) v); + } + + // Fallbacks + if (endpointUri.getExpressionEndpointURI() != null) { + return stringifyRuntimeExpression(endpointUri.getExpressionEndpointURI()); + } + if (endpointUri.getLiteralEndpointURI() != null) { + return stringifyUriTemplate(endpointUri.getLiteralEndpointURI()); + } + + return "null"; + } + + private static String stringifyUriTemplate(UriTemplate t) { + if (t == null) return "null"; + + Object v = t.get(); + if (v instanceof URI) { + return v.toString(); + } else if (v instanceof String) { + return (String) v; // template like "https://{host}/x" + } + + // Fallbacks + if (t.getLiteralUri() != null) { + return t.getLiteralUri().toString(); + } + if (t.getLiteralUriTemplate() != null) { + return t.getLiteralUriTemplate(); + } + return "null"; + } + + private static String stringifyRuntimeExpression(String s) { + return s == null ? "null" : s.trim(); + } + + // ------------------- auth rendering ------------------- + + public static String summarizeAuth(ReferenceableAuthenticationPolicy refAuth) { + if (refAuth == null) return "none"; + + Object v = refAuth.get(); + if (v instanceof AuthenticationPolicyReference) { + String name = ((AuthenticationPolicyReference) v).getUse(); + return name == null || name.isBlank() ? "ref:" : "ref:" + name; + } + if (v instanceof AuthenticationPolicyUnion) { + return summarizeInlinePolicy((AuthenticationPolicyUnion) v); + } + + // Fallbacks if union discriminator wasn't set + if (refAuth.getAuthenticationPolicyReference() != null) { + String name = refAuth.getAuthenticationPolicyReference().getUse(); + return name == null || name.isBlank() ? "ref:" : "ref:" + name; + } + if (refAuth.getAuthenticationPolicy() != null) { + return summarizeInlinePolicy(refAuth.getAuthenticationPolicy()); + } + + return "none"; + } + + private static String summarizeInlinePolicy(AuthenticationPolicyUnion union) { + if (union == null) return "none"; + + AuthenticationPolicy concrete = union.get(); + if (concrete != null) { + return normalizePolicyName(concrete.getClass().getSimpleName()); + } + + // Fallbacks by field if discriminator not set + if (union.getBasicAuthenticationPolicy() != null) { + return normalizePolicyName(union.getBasicAuthenticationPolicy().getClass().getSimpleName()); + } + if (union.getBearerAuthenticationPolicy() != null) { + return normalizePolicyName(union.getBearerAuthenticationPolicy().getClass().getSimpleName()); + } + if (union.getDigestAuthenticationPolicy() != null) { + return normalizePolicyName(union.getDigestAuthenticationPolicy().getClass().getSimpleName()); + } + if (union.getOAuth2AuthenticationPolicy() != null) { + return normalizePolicyName(union.getOAuth2AuthenticationPolicy().getClass().getSimpleName()); + } + if (union.getOpenIdConnectAuthenticationPolicy() != null) { + return normalizePolicyName( + union.getOpenIdConnectAuthenticationPolicy().getClass().getSimpleName()); + } + return "none"; + } + + /** + * Turns "BasicAuthenticationPolicy" -> "basic", "OAuth2AuthenticationPolicy" -> "oauth2", + * "OpenIdConnectAuthenticationPolicy" -> "openidconnect", etc. + */ + private static String normalizePolicyName(String simpleName) { + if (simpleName == null || simpleName.isEmpty()) return "unknown"; + String name = simpleName; + if (name.endsWith("AuthenticationPolicy")) { + name = name.substring(0, name.length() - "AuthenticationPolicy".length()); + } + return name.toLowerCase(); + } +} diff --git a/mermaid/src/main/java/io/serverlessworkflow/mermaid/ForNode.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/ForNode.java index c58e41383..5a1f56741 100644 --- a/mermaid/src/main/java/io/serverlessworkflow/mermaid/ForNode.java +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/ForNode.java @@ -43,18 +43,18 @@ public class ForNode extends TaskSubgraphNode { Node loop = NodeBuilder.split(); this.addBranch(loop.getId(), loop); - this.branches.putAll(new MermaidGraph().build(forTask.getDo())); + this.addBranches(new MermaidGraph().build(forTask.getDo())); final Node firstTask = this.branches.get(forTask.getDo().get(0).getName()); - note.setNext(loop); - loop.setNext(firstTask); + note.addEdge(Edge.to(loop)); + loop.addEdge(Edge.to(firstTask)); String lastForTask = forTask.getDo().get(forTask.getDo().size() - 1).getName(); - String renderedArrow = "-. |next| .->"; + String renderedArrow = "-. |edge| .->"; if (forTask.getWhile() != null && !forTask.getWhile().isEmpty()) { - renderedArrow = "-. |while: " + NodeRenderer.escNodeLabel(forTask.getWhile()) + "| .->"; + renderedArrow = "-. |while: " + NodeRenderer.escLabel(forTask.getWhile()) + "| .->"; } - this.getBranches().get(lastForTask).withNext(loop).setRenderedArrow(renderedArrow); + this.getBranches().get(lastForTask).withEdge(Edge.to(loop).withArrow(renderedArrow)); } } diff --git a/mermaid/src/main/java/io/serverlessworkflow/mermaid/ForkNode.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/ForkNode.java index 1b66844c1..d3d5dc27a 100644 --- a/mermaid/src/main/java/io/serverlessworkflow/mermaid/ForkNode.java +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/ForkNode.java @@ -34,9 +34,9 @@ public ForkNode(TaskItem task) { this.setDirection("LR"); // Split and join badges - SplitNode split = NodeBuilder.split(); + Node split = NodeBuilder.split(); String competeLabel = fork.getFork().isCompete() ? "ANY" : "ALL"; - Node join = new Node(Ids.newId(), competeLabel, NodeType.JUNCTION); + Node join = new Node(Ids.random(), competeLabel, NodeType.JUNCTION); this.addBranch(split.getId(), split); this.addBranch(join.getId(), join); @@ -62,9 +62,8 @@ public ForkNode(TaskItem task) { for (TaskItem branchRoot : branches) { String name = branchRoot.getName(); Node branch = branchRoots.get(name); - split.addNext(branch); - branch.setNext(join); - branch.setRenderedArrow("-- |" + competeLabel + "| -->"); + split.addEdge(Edge.to(branch)); + branch.addEdge(Edge.to(join).withArrow("-- |" + competeLabel + "| -->")); } } } diff --git a/mermaid/src/main/java/io/serverlessworkflow/mermaid/Ids.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/Ids.java index 051df4517..424d65881 100644 --- a/mermaid/src/main/java/io/serverlessworkflow/mermaid/Ids.java +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/Ids.java @@ -15,6 +15,10 @@ */ package io.serverlessworkflow.mermaid; +import io.serverlessworkflow.api.types.TaskItem; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.text.Normalizer; import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.atomic.AtomicInteger; @@ -22,11 +26,49 @@ public final class Ids { private final String salt = Integer.toString(ThreadLocalRandom.current().nextInt(), 36); private final AtomicInteger seq = new AtomicInteger(); - private String build() { - return "n_" + salt + "_" + Integer.toString(seq.getAndIncrement(), 36); + public static String random() { + return new Ids().build(); } - public static String newId() { - return new Ids().build(); + public static String of(TaskItem task) { + String slug = slug(task.getName()); + String h = shortHash(task.getName()); + return "n_" + slug + "_" + h; + } + + public static String of(String taskName) { + String slug = slug(taskName); + String h = shortHash(taskName); + return "n_" + slug + "_" + h; + } + + /** Lowercase slug for Mermaid ids: letters/digits/hyphen only; must start with a letter. */ + private static String slug(String s) { + if (s == null || s.isBlank()) return "x"; + String n = + Normalizer.normalize(s, Normalizer.Form.NFKD) + .replaceAll("[^\\p{Alnum}]+", "-") + .replaceAll("(^-+|-+$)", "") + .toLowerCase(); + if (n.isEmpty() || !Character.isLetter(n.charAt(0))) n = "x-" + n; + return n; + } + + private static String shortHash(String s) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] d = md.digest(s.getBytes(StandardCharsets.UTF_8)); + // first 6 bytes => 12 hex chars; small + stable + StringBuilder sb = new StringBuilder(12); + for (int i = 0; i < 6; i++) sb.append(String.format("%02x", d[i])); + return sb.toString(); + } catch (Exception e) { + // Very unlikely; fallback to simple sanitized length if crypto unavailable + return Integer.toHexString(s.hashCode()); + } + } + + private String build() { + return "n_" + salt + "_" + Integer.toString(seq.getAndIncrement(), 36); } } diff --git a/mermaid/src/main/java/io/serverlessworkflow/mermaid/IteratorNode.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/IteratorNode.java index 85219549c..fc40dcb28 100644 --- a/mermaid/src/main/java/io/serverlessworkflow/mermaid/IteratorNode.java +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/IteratorNode.java @@ -20,7 +20,7 @@ public class IteratorNode extends SubgraphNode { public IteratorNode(String label, SubscriptionIterator iterator) { - super(Ids.newId(), label); + super(Ids.random(), label); if (iterator.getDo().isEmpty()) { return; @@ -32,15 +32,15 @@ public IteratorNode(String label, SubscriptionIterator iterator) { Node loop = NodeBuilder.junction(); this.addBranch(loop.getId(), loop); - this.branches.putAll(new MermaidGraph().build(iterator.getDo())); + this.addBranches(new MermaidGraph().build(iterator.getDo())); final Node firstTask = this.branches.get(iterator.getDo().get(0).getName()); - note.setNext(loop); - loop.setNext(firstTask); + note.addEdge(Edge.to(loop)); + loop.addEdge(Edge.to(firstTask)); String lastForTask = iterator.getDo().get(iterator.getDo().size() - 1).getName(); - String renderedArrow = "-. |next| .->"; + String renderedArrow = "-. |edge| .->"; - this.getBranches().get(lastForTask).withNext(loop).setRenderedArrow(renderedArrow); + this.getBranches().get(lastForTask).withEdge(Edge.to(loop).withArrow(renderedArrow)); } } diff --git a/mermaid/src/main/java/io/serverlessworkflow/mermaid/ListenNode.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/ListenNode.java index cdc2183c5..b4be12cac 100644 --- a/mermaid/src/main/java/io/serverlessworkflow/mermaid/ListenNode.java +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/ListenNode.java @@ -45,7 +45,7 @@ public ListenNode(TaskItem task) { junctionArrow = String.format( "-. until: %s .->", - NodeRenderer.escNodeLabel( + NodeRenderer.escLabel( to.getAnyEventConsumptionStrategy().getUntil().get().toString())); } } else if (to.getOneEventConsumptionStrategy() != null) { @@ -66,8 +66,8 @@ public ListenNode(TaskItem task) { Node junctionNote = NodeBuilder.junction(); Node inner = NodeBuilder.rect(task.getName()); - junctionNote.withNext(inner); - nodeNote.withNext(junctionNote); + junctionNote.withEdge(Edge.to(inner)); + nodeNote.withEdge(Edge.to(junctionNote)); this.addBranch("note", nodeNote); this.addBranch("junction", junctionNote); @@ -78,14 +78,15 @@ public ListenNode(TaskItem task) { && !listenTask.getForeach().getDo().isEmpty()) { Node forEach = new IteratorNode("for:", listenTask.getForeach()); this.addBranch("forEach", forEach); - inner.setNext(forEach); - forEach.setNext(junctionNote); + Edge forEachEdge = Edge.to(forEach); + inner.addEdge(forEachEdge); + forEach.addEdge(Edge.to(junctionNote)); if (!junctionArrow.isEmpty()) { - forEach.setRenderedArrow(junctionArrow); + forEachEdge.setArrow(junctionArrow); } } else if (!junctionArrow.isEmpty()) { - inner.withNext(junctionNote).setRenderedArrow(junctionArrow); + inner.withEdge(Edge.to(junctionNote).withArrow(junctionArrow)); } } } diff --git a/mermaid/src/main/java/io/serverlessworkflow/mermaid/Mermaid.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/Mermaid.java index e2cde7a49..befc2bcaa 100644 --- a/mermaid/src/main/java/io/serverlessworkflow/mermaid/Mermaid.java +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/Mermaid.java @@ -20,6 +20,7 @@ import java.io.IOException; import java.util.Map; +/** Main entrypoint to generate a Mermaid representation of a Workflow definition. */ public class Mermaid { private static final String FLOWCHART = "flowchart TD\n"; diff --git a/mermaid/src/main/java/io/serverlessworkflow/mermaid/MermaidGraph.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/MermaidGraph.java index a0910e88d..39385bc4f 100644 --- a/mermaid/src/main/java/io/serverlessworkflow/mermaid/MermaidGraph.java +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/MermaidGraph.java @@ -26,6 +26,9 @@ final class MermaidGraph { + static final String START_NODE_ID = "n__start__"; + static final String END_NODE_ID = "n__end__"; + MermaidGraph() {} private static FlowDirective extractThen(TaskItem task) { @@ -47,31 +50,44 @@ private static TaskBase toTaskBase(TaskItem task) { } Map buildWithTerminals(List tasks) { - final Map graph = this.build(tasks); - final Node startNode = new Node(Ids.newId(), "__start", NodeType.START); - final Node endNode = new Node(Ids.newId(), "__end", NodeType.STOP); + final Map graph = new LinkedHashMap<>(this.build(tasks)); + final Node startNode = new Node(START_NODE_ID, "Start", NodeType.START); + final Node endNode = new Node(END_NODE_ID, "End", NodeType.STOP); for (Node n : graph.values()) { - if (n.getNext() == null && n.getType() != NodeType.START && n.getType() != NodeType.STOP) { - n.setNext(endNode); + if (n.getEdge().isEmpty() && n.getType() != NodeType.START && n.getType() != NodeType.STOP) { + n.addEdge(Edge.to(endNode)); } } - graph.put("start", startNode.withNext(graph.get(tasks.get(0).getName()))); - graph.put("end", endNode); + graph.put(START_NODE_ID, startNode.withEdge(Edge.to(graph.get(tasks.get(0).getName())))); + graph.put(END_NODE_ID, endNode); return graph; } - Map build(List tasks) { - Map graph = new LinkedHashMap<>(Math.max(16, tasks.size() * 2)); + Map build(List tasks) { + Map graph = new LinkedHashMap<>(Math.max(16, tasks.size() * 2)); for (int i = 0; i < tasks.size(); i++) { TaskItem task = tasks.get(i); - Node u = graph.computeIfAbsent(task.getName(), n -> NodeBuilder.task(task)); + TaskNode u = graph.computeIfAbsent(task.getName(), n -> NodeBuilder.task(task)); + + // Switch and Raise handles the graph differently + if (NodeType.SWITCH.equals(u.getType()) || NodeType.RAISE.equals(u.getType())) { + continue; + } + FlowDirective next = extractThen(task); if ((next == null || FlowDirectiveEnum.CONTINUE.equals(next.getFlowDirectiveEnum())) && (i + 1 < tasks.size())) { TaskItem nextTask = tasks.get(i + 1); - Node v = graph.computeIfAbsent(nextTask.getName(), n -> NodeBuilder.task(nextTask)); - u.setNext(v); + TaskNode v = graph.computeIfAbsent(nextTask.getName(), n -> NodeBuilder.task(nextTask)); + u.addEdge(Edge.to(v)); + } else if (next != null && next.getFlowDirectiveEnum() != null) { + switch (next.getFlowDirectiveEnum()) { + case EXIT: // TODO: exit should have a X node edge + case END: + u.addEdge(Edge.toEnd()); + break; + } } } @@ -88,7 +104,7 @@ Map build(List tasks) { + cur.getName() + "')"); } - from.setNext(to); + from.addEdge(Edge.to(to)); } } diff --git a/mermaid/src/main/java/io/serverlessworkflow/mermaid/MermaidInk.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/MermaidInk.java new file mode 100644 index 000000000..64a45c74c --- /dev/null +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/MermaidInk.java @@ -0,0 +1,71 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed 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 io.serverlessworkflow.mermaid; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Base64; +import java.util.zip.Deflater; + +/** + * Exports a mermaid workflow representation to a PNG or SVG file by encoding string and calling the + * remote service mermaid.ink. Depends on the website to be available. + */ +public final class MermaidInk { + + static String encode(String mermaid) { + Deflater deflater = + new Deflater(Deflater.BEST_COMPRESSION, true); // 'true' => raw DEFLATE (pako-compatible) + deflater.setInput(mermaid.getBytes(StandardCharsets.UTF_8)); + deflater.finish(); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + byte[] buf = new byte[4096]; + while (!deflater.finished()) baos.write(buf, 0, deflater.deflate(buf)); + return "pako:" + Base64.getUrlEncoder().withoutPadding().encodeToString(baos.toByteArray()); + } + + public static Path render(String mermaid, boolean svg, Path outFile) { + String encoded = encode(mermaid); + String base = svg ? "https://mermaid.ink/svg/" : "https://mermaid.ink/img/"; + String url = svg ? base + encoded : base + encoded + "?type=png"; + HttpClient client = HttpClient.newHttpClient(); + HttpRequest req = HttpRequest.newBuilder().uri(URI.create(url)).build(); + HttpResponse resp; + + try { + resp = client.send(req, HttpResponse.BodyHandlers.ofByteArray()); + } catch (IOException | InterruptedException e) { + throw new RuntimeException("Failed to call mermaid.ink website", e); + } + + if (resp.statusCode() != 200) + throw new RuntimeException("mermaid.ink request failed: " + resp.statusCode()); + + try { + Files.write(outFile, resp.body()); + } catch (IOException e) { + throw new RuntimeException("Failed to save file in the given path: " + outFile, e); + } + return outFile; + } +} diff --git a/mermaid/src/main/java/io/serverlessworkflow/mermaid/Node.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/Node.java index 0c4095e94..00e0916fc 100644 --- a/mermaid/src/main/java/io/serverlessworkflow/mermaid/Node.java +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/Node.java @@ -16,17 +16,21 @@ package io.serverlessworkflow.mermaid; import java.io.Serializable; +import java.util.Collections; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.Map; import java.util.Objects; +import java.util.Set; public class Node implements Serializable { protected final String id; + protected final Set edge; + protected final Map branches; protected String label; - protected Node next; protected NodeType type; - protected Map branches; protected NodeRenderer renderer; + private String defaultEdgeArrow; public Node(String id, String label) { this.id = id; @@ -34,6 +38,7 @@ public Node(String id, String label) { this.type = NodeType.RECT; this.branches = new LinkedHashMap<>(); this.renderer = new DefaultNodeRenderer(this); + this.edge = new HashSet<>(); } public Node(String id, String label, NodeType type) { @@ -41,8 +46,8 @@ public Node(String id, String label, NodeType type) { this.type = type; } - public Node withNext(Node next) { - this.next = next; + public Node withEdge(Edge edge) { + this.edge.add(edge); return this; } @@ -50,12 +55,18 @@ public NodeType getType() { return type; } - public Node getNext() { - return next; + public Set getEdge() { + return Collections.unmodifiableSet(edge); } - public void setNext(Node next) { - this.next = next; + public void addEdge(Edge edge) { + if (edge == null) { + return; + } + if (defaultEdgeArrow != null) { + edge.setArrow(defaultEdgeArrow); + } + this.edge.add(edge); } public String getId() { @@ -63,7 +74,7 @@ public String getId() { } public String getLabel() { - return NodeRenderer.escNodeLabel(label); + return NodeRenderer.escLabel(label); } public void setLabel(String label) { @@ -74,7 +85,7 @@ public void addBranch(String name, Node branch) { branches.put(name, branch); } - public void addBranches(Map branches) { + public void addBranches(Map branches) { this.branches.putAll(branches); } @@ -82,8 +93,9 @@ public Map getBranches() { return branches; } - public void setRenderedArrow(String renderedArrow) { - this.renderer.setRenderedArrow(renderedArrow); + public Node withDefaultEdgeArrow(String edgeArrow) { + this.defaultEdgeArrow = edgeArrow; + return this; } /** Renders the Mermaid representation of this node. */ @@ -96,8 +108,8 @@ public String toString() { return "Node{" + "type=" + type - + ", next=" - + next + + ", edge=" + + edge + ", label='" + label + '\'' @@ -113,12 +125,12 @@ public boolean equals(Object o) { Node node = (Node) o; return Objects.equals(id, node.id) && Objects.equals(label, node.label) - && Objects.equals(next, node.next) + && Objects.equals(edge, node.edge) && type == node.type; } @Override public int hashCode() { - return Objects.hash(id, label, next, type); + return Objects.hash(id, label, edge, type); } } diff --git a/mermaid/src/main/java/io/serverlessworkflow/mermaid/NodeBuilder.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/NodeBuilder.java index 0ca4c74ab..7b52a0ce5 100644 --- a/mermaid/src/main/java/io/serverlessworkflow/mermaid/NodeBuilder.java +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/NodeBuilder.java @@ -15,50 +15,65 @@ */ package io.serverlessworkflow.mermaid; +import io.serverlessworkflow.api.types.CallTask; import io.serverlessworkflow.api.types.DoTask; import io.serverlessworkflow.api.types.EmitTask; import io.serverlessworkflow.api.types.ForTask; import io.serverlessworkflow.api.types.ForkTask; import io.serverlessworkflow.api.types.ListenTask; +import io.serverlessworkflow.api.types.RaiseTask; +import io.serverlessworkflow.api.types.RunTask; +import io.serverlessworkflow.api.types.SetTask; +import io.serverlessworkflow.api.types.SwitchTask; import io.serverlessworkflow.api.types.TaskItem; import io.serverlessworkflow.api.types.TryTask; +import io.serverlessworkflow.api.types.WaitTask; public final class NodeBuilder { private NodeBuilder() {} public static Node note(String label) { - Node node = new Node(Ids.newId(), label, NodeType.NOTE); - node.setRenderedArrow("-.->"); - return node; + return new Node(Ids.random(), label, NodeType.NOTE).withDefaultEdgeArrow(Edge.ARROW_DOTTED); + } + + public static Node comment(String label) { + return new Node(Ids.random(), label, NodeType.COMMENT).withDefaultEdgeArrow("-.-"); } public static Node junction() { - return new Node(Ids.newId(), "join", NodeType.JUNCTION); + return new Node(Ids.random(), "join", NodeType.JUNCTION); } - public static SplitNode split() { - return new SplitNode(Ids.newId(), "split"); + public static Node split() { + return new Node(Ids.random(), "split", NodeType.SPLIT); } public static Node rect(String label) { - return new Node(Ids.newId(), label, NodeType.RECT); + return new Node(Ids.random(), label, NodeType.RECT); } public static Node tryBlock() { - return new TryBlockNode(); + return new SubgraphNode(Ids.random(), "Try", NodeType.TRY_BLOCK) + .withDefaultEdgeArrow("-. |onError| .->"); } public static Node subgraph(String label) { - return new SubgraphNode(Ids.newId(), label); + return new SubgraphNode(Ids.random(), label); } - public static Node task(TaskItem task) { + public static Node error() { + return new Node(Ids.random(), "error", NodeType.ERROR); + } + + public static TaskNode task(TaskItem task) { if (task.getTask().get() instanceof TryTask) { return new TryCatchNode(task); } else if (task.getTask().get() instanceof DoTask) { return new TaskSubgraphNode(task, String.format("do: %s", task.getName())) .withBranches(task.getTask().getDoTask().getDo()); + } else if (task.getTask().get() instanceof SetTask) { + return new TaskNode(String.format("set: %s", task.getName()), task, NodeType.RECT); } else if (task.getTask().get() instanceof ForTask) { return new ForNode(task); } else if (task.getTask().get() instanceof ListenTask) { @@ -67,8 +82,18 @@ public static Node task(TaskItem task) { return new EmitNode(task); } else if (task.getTask().get() instanceof ForkTask) { return new ForkNode(task); + } else if (task.getTask().get() instanceof SwitchTask) { + return new SwitchNode(task); + } else if (task.getTask().get() instanceof RaiseTask) { + return new RaiseNode(task); + } else if (task.getTask().get() instanceof RunTask) { + return new RunNode(task); + } else if (task.getTask().get() instanceof WaitTask) { + return new WaitNode(task); + } else if (task.getTask().get() instanceof CallTask) { + return new CallNode(task); } - // TODO: Switch, Raise, Run, Set, Wait, Call - return new TaskNode(task.getName(), task); + + return new TaskNode(task.getName(), task, NodeType.RECT); } } diff --git a/mermaid/src/main/java/io/serverlessworkflow/mermaid/NodeRenderer.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/NodeRenderer.java index 60424b30e..67d52a246 100644 --- a/mermaid/src/main/java/io/serverlessworkflow/mermaid/NodeRenderer.java +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/NodeRenderer.java @@ -19,12 +19,10 @@ public interface NodeRenderer { void render(StringBuilder sb, int level); - void setRenderedArrow(String renderedArrow); - - static String escNodeLabel(String s) { + static String escLabel(String s) { if (s == null) return ""; return s.replace("\\", "\\\\") - .replace("\"", "\\\"") + .replace("\"", "#quot;") .replace("]", "\\]") .replace("[", "\\[") .replace("\r\n", "
") diff --git a/mermaid/src/main/java/io/serverlessworkflow/mermaid/NodeType.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/NodeType.java index 7ed9c12f5..8a243cdb7 100644 --- a/mermaid/src/main/java/io/serverlessworkflow/mermaid/NodeType.java +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/NodeType.java @@ -19,6 +19,7 @@ public enum NodeType { RECT("rect"), STOP("stop"), SUBGRAPH("subgraph"), + SWITCH("diam"), TRY_CATCH("subgraph"), TRY_BLOCK("subgraph"), NOTE("note"), @@ -26,6 +27,10 @@ public enum NodeType { START("sm-circ"), EVENT("rounded"), EMIT("lean-r"), + ERROR("cross-circ"), + RAISE("trap-b"), + COMMENT("braces"), + WAIT("hourglass"), JUNCTION("f-circ"); private final String type; diff --git a/mermaid/src/main/java/io/serverlessworkflow/mermaid/SplitNodeRenderer.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/RaiseNode.java similarity index 60% rename from mermaid/src/main/java/io/serverlessworkflow/mermaid/SplitNodeRenderer.java rename to mermaid/src/main/java/io/serverlessworkflow/mermaid/RaiseNode.java index 6348952ce..bc32f4b13 100644 --- a/mermaid/src/main/java/io/serverlessworkflow/mermaid/SplitNodeRenderer.java +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/RaiseNode.java @@ -15,22 +15,18 @@ */ package io.serverlessworkflow.mermaid; -public class SplitNodeRenderer extends DefaultNodeRenderer { +import io.serverlessworkflow.api.types.TaskItem; - public SplitNodeRenderer(SplitNode node) { - super(node); - } - - @Override - protected void renderNext(StringBuilder sb, int level) { - SplitNode splitNode = (SplitNode) this.getNode(); +public class RaiseNode extends TaskNode { - for (Node next : splitNode.getNexts()) { - sb.append(ind(level)) - .append(this.getNode().getId()) - .append(renderedArrow) - .append(next.getId()) - .append("\n"); + public RaiseNode(TaskItem task) { + super(String.format("raise: %s", task.getName()), task, NodeType.RAISE); + if (task.getTask().getRaiseTask() == null) { + throw new IllegalStateException("Raise node must have a raise task"); } + + Node errorNode = NodeBuilder.error(); + this.addBranch("error", errorNode); + this.addEdge(Edge.to(errorNode)); } } diff --git a/mermaid/src/main/java/io/serverlessworkflow/mermaid/RunNode.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/RunNode.java new file mode 100644 index 000000000..31d26f530 --- /dev/null +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/RunNode.java @@ -0,0 +1,50 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed 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 io.serverlessworkflow.mermaid; + +import io.serverlessworkflow.api.types.RunTask; +import io.serverlessworkflow.api.types.TaskItem; + +public class RunNode extends TaskNode { + + public RunNode(TaskItem task) { + super("", task, NodeType.RECT); + + if (task.getTask().getRunTask() == null) { + throw new IllegalArgumentException("Run node must be a run task"); + } + + RunTask runTask = task.getTask().getRunTask(); + String label = String.format("%s", NodeRenderer.escLabel(task.getName())); + if (runTask.getRun().getRunWorkflow() != null) { + label = + NodeRenderer.escLabel( + String.format( + "%s
**run workflow:** \"%s\"", + label, runTask.getRun().getRunWorkflow().getWorkflow().getName())); + } else if (runTask.getRun().getRunContainer() != null) { + label = + NodeRenderer.escLabel( + String.format( + "%s
**run container:** \"%s\"", + label, runTask.getRun().getRunContainer().getContainer().getImage())); + } else if (runTask.getRun().getRunScript() != null || runTask.getRun().getRunShell() != null) { + label = NodeRenderer.escLabel(String.format("run script: \"%s\"", task.getName())); + } + + this.label = label; + } +} diff --git a/mermaid/src/main/java/io/serverlessworkflow/mermaid/SplitNode.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/SplitNode.java deleted file mode 100644 index 5588bbcd9..000000000 --- a/mermaid/src/main/java/io/serverlessworkflow/mermaid/SplitNode.java +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright 2020-Present The Serverless Workflow Specification Authors - * - * Licensed 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 io.serverlessworkflow.mermaid; - -import java.util.Collections; -import java.util.HashSet; -import java.util.Set; - -/** Split nodes can have more than one exit point */ -public class SplitNode extends Node { - - private final Set nexts = new HashSet<>(); - - public SplitNode(String id, String label) { - super(id, label, NodeType.SPLIT); - this.renderer = new SplitNodeRenderer(this); - } - - public void addNext(Node next) { - nexts.add(next); - } - - /** - * Same as #addNext - * - * @param next - */ - @Override - public void setNext(Node next) { - this.addNext(next); - super.setNext(next); - } - - /** - * Gets the first next in the set - * - * @return - */ - @Override - public Node getNext() { - if (this.nexts.isEmpty()) { - return null; - } - return this.nexts.iterator().next(); - } - - public Set getNexts() { - return Collections.unmodifiableSet(nexts); - } -} diff --git a/mermaid/src/main/java/io/serverlessworkflow/mermaid/SubgraphNodeRenderer.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/SubgraphNodeRenderer.java index dc7577015..2054a4f9e 100644 --- a/mermaid/src/main/java/io/serverlessworkflow/mermaid/SubgraphNodeRenderer.java +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/SubgraphNodeRenderer.java @@ -33,10 +33,10 @@ public void render(StringBuilder sb, int level) { .append("subgraph ") .append(getNode().getId()) .append("[\"") - .append(NodeRenderer.escNodeLabel(getNode().getLabel())) + .append(NodeRenderer.escLabel(getNode().getLabel())) .append("\"]\n"); this.renderBody(sb, level); - this.renderNext(sb, level); + this.renderEdge(sb, level); } @Override diff --git a/mermaid/src/main/java/io/serverlessworkflow/mermaid/SwitchNode.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/SwitchNode.java new file mode 100644 index 000000000..0d3989256 --- /dev/null +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/SwitchNode.java @@ -0,0 +1,56 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed 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 io.serverlessworkflow.mermaid; + +import io.serverlessworkflow.api.types.SwitchItem; +import io.serverlessworkflow.api.types.TaskItem; + +public class SwitchNode extends TaskNode { + + public SwitchNode(TaskItem task) { + super(String.format("switch: %s", task.getName()), task, NodeType.SWITCH); + + if (task.getTask().getSwitchTask() == null) { + throw new IllegalStateException("Switch node must have a switch task"); + } + + for (SwitchItem item : task.getTask().getSwitchTask().getSwitch()) { + if (item.getSwitchCase().getThen().getFlowDirectiveEnum() != null) { + Edge caseEdge = + switch (item.getSwitchCase().getThen().getFlowDirectiveEnum()) { + case EXIT, END -> + Edge.toEnd() + .withArrow( + String.format( + "--**when:** %s-->", + NodeRenderer.escLabel(item.getSwitchCase().getWhen()))); + case CONTINUE -> null; + }; + this.addEdge(caseEdge); + } else if (item.getSwitchCase().getThen().getString() != null) { + Edge caseEdge = Edge.to(item.getSwitchCase().getThen().getString()); + if (item.getSwitchCase().getWhen() != null) { + caseEdge.setArrow( + String.format( + "--**when:** %s-->", NodeRenderer.escLabel(item.getSwitchCase().getWhen()))); + } else { + caseEdge.setArrow("--default-->"); + } + this.addEdge(caseEdge); + } + } + } +} diff --git a/mermaid/src/main/java/io/serverlessworkflow/mermaid/TaskNode.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/TaskNode.java index 6f8351b11..0afc21dde 100644 --- a/mermaid/src/main/java/io/serverlessworkflow/mermaid/TaskNode.java +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/TaskNode.java @@ -15,52 +15,19 @@ */ package io.serverlessworkflow.mermaid; -import io.serverlessworkflow.api.types.CallTask; -import io.serverlessworkflow.api.types.DoTask; -import io.serverlessworkflow.api.types.EmitTask; -import io.serverlessworkflow.api.types.ForTask; -import io.serverlessworkflow.api.types.ForkTask; -import io.serverlessworkflow.api.types.ListenTask; -import io.serverlessworkflow.api.types.RaiseTask; -import io.serverlessworkflow.api.types.RunTask; -import io.serverlessworkflow.api.types.SetTask; -import io.serverlessworkflow.api.types.SwitchTask; import io.serverlessworkflow.api.types.TaskItem; -import io.serverlessworkflow.api.types.TryTask; -import io.serverlessworkflow.api.types.WaitTask; -import java.util.Map; public class TaskNode extends Node { - protected static final Map, NodeType> NODE_TYPE_BY_CLASS = - Map.ofEntries( - Map.entry(CallTask.class, NodeType.RECT), - Map.entry(DoTask.class, NodeType.SUBGRAPH), - Map.entry(ForkTask.class, NodeType.SUBGRAPH), - Map.entry(EmitTask.class, NodeType.EMIT), - Map.entry(ForTask.class, NodeType.SUBGRAPH), - Map.entry(ListenTask.class, NodeType.EVENT), - Map.entry(RaiseTask.class, NodeType.RECT), - Map.entry(RunTask.class, NodeType.RECT), - Map.entry(SetTask.class, NodeType.RECT), - Map.entry(SwitchTask.class, NodeType.SUBGRAPH), - Map.entry(TryTask.class, NodeType.TRY_CATCH), - Map.entry(WaitTask.class, NodeType.RECT)); protected final TaskItem task; - public TaskNode(String label, TaskItem task) { - super(Ids.newId(), label); - this.task = task; - - Object concrete = task.getTask().get(); - Class cls = concrete.getClass(); - - this.type = NODE_TYPE_BY_CLASS.getOrDefault(cls, NodeType.RECT); - } - public TaskNode(String label, TaskItem task, NodeType type) { - super(Ids.newId(), label); + super(Ids.of(task), label); this.task = task; this.type = type; } + + public TaskItem getTask() { + return task; + } } diff --git a/mermaid/src/main/java/io/serverlessworkflow/mermaid/TryBlockNode.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/TryBlockNode.java deleted file mode 100644 index 647875437..000000000 --- a/mermaid/src/main/java/io/serverlessworkflow/mermaid/TryBlockNode.java +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright 2020-Present The Serverless Workflow Specification Authors - * - * Licensed 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 io.serverlessworkflow.mermaid; - -public class TryBlockNode extends SubgraphNode { - - public TryBlockNode() { - super(Ids.newId(), "Try", NodeType.TRY_BLOCK); - this.renderer.setRenderedArrow("-. |onError| .->"); - } -} diff --git a/mermaid/src/main/java/io/serverlessworkflow/mermaid/TryCatchNode.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/TryCatchNode.java index 5753a0c5a..9f7155dd5 100644 --- a/mermaid/src/main/java/io/serverlessworkflow/mermaid/TryCatchNode.java +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/TryCatchNode.java @@ -37,7 +37,7 @@ public TryCatchNode(TaskItem task) { new MermaidGraph().build(task.getTask().getTryTask().getCatch().getDo())); this.addBranch("catch_lane", catchNode); - tryNode.setNext(catchNode); + tryNode.addEdge(Edge.to(catchNode)); } } } diff --git a/mermaid/src/main/java/io/serverlessworkflow/mermaid/WaitNode.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/WaitNode.java new file mode 100644 index 000000000..08301ed54 --- /dev/null +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/WaitNode.java @@ -0,0 +1,107 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed 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 io.serverlessworkflow.mermaid; + +import io.serverlessworkflow.api.types.DurationInline; +import io.serverlessworkflow.api.types.TaskItem; +import io.serverlessworkflow.api.types.TimeoutAfter; +import java.util.Objects; + +public class WaitNode extends TaskNode { + + public WaitNode(TaskItem task) { + super("wait", task, NodeType.WAIT); + + if (task.getTask().getWaitTask() == null) { + throw new IllegalArgumentException("Wait node requires a wait task"); + } + + Node commentNode = + NodeBuilder.comment(WaitTaskStringify.of(task.getTask().getWaitTask().getWait())); + commentNode.addEdge(Edge.to(this)); + this.addBranch("comment", commentNode); + } + + static final class WaitTaskStringify { + + private static final long MILLIS_PER_SECOND = 1_000L; + private static final long MILLIS_PER_MINUTE = 60_000L; + private static final long MILLIS_PER_HOUR = 3_600_000L; + private static final long MILLIS_PER_DAY = 86_400_000L; + + private WaitTaskStringify() {} + + /** + * Formats the duration verbosely with pluralization (e.g. "2 days 3 hours 4 minutes 5 seconds + * 120 milliseconds"). + */ + public static String of(TimeoutAfter timeoutAfter) { + Objects.requireNonNull(timeoutAfter, "TimeoutAfter must not be null"); + if (timeoutAfter.getDurationExpression() != null + && !timeoutAfter.getDurationExpression().isEmpty()) { + return timeoutAfter.getDurationExpression(); + } + DurationInline d = timeoutAfter.getDurationInline(); + if (d == null) { + return ""; + } + + long totalMillis = + (long) d.getDays() * MILLIS_PER_DAY + + (long) d.getHours() * MILLIS_PER_HOUR + + (long) d.getMinutes() * MILLIS_PER_MINUTE + + (long) d.getSeconds() * MILLIS_PER_SECOND + + (long) d.getMilliseconds(); + + if (totalMillis == 0L) { + return "0 seconds"; + } + + String sign = totalMillis < 0 ? "-" : ""; + long ms = Math.abs(totalMillis); + + long days = ms / MILLIS_PER_DAY; + ms %= MILLIS_PER_DAY; + long hours = ms / MILLIS_PER_HOUR; + ms %= MILLIS_PER_HOUR; + long mins = ms / MILLIS_PER_MINUTE; + ms %= MILLIS_PER_MINUTE; + long secs = ms / MILLIS_PER_SECOND; + ms %= MILLIS_PER_SECOND; + + StringBuilder sb = new StringBuilder(sign); + sb.append("Wait for "); + append(sb, days, "day", "days"); + append(sb, hours, "hour", "hours"); + append(sb, mins, "minute", "minutes"); + append(sb, secs, "second", "seconds"); + append(sb, ms, "millisecond", "milliseconds"); + + return sb.toString().trim(); + } + + private static void append(StringBuilder sb, long value, String singular, String plural) { + if (value <= 0) return; + if (needsSpace(sb)) sb.append(' '); + sb.append(value).append(' ').append(value == 1 ? singular : plural); + } + + private static boolean needsSpace(CharSequence sb) { + int len = sb.length(); + return len > 0 && sb.charAt(len - 1) != '-'; + } + } +} diff --git a/mermaid/src/test/java/io/serverlessworkflow/mermaid/ClasspathYamlFinder.java b/mermaid/src/test/java/io/serverlessworkflow/mermaid/ClasspathYamlFinder.java new file mode 100644 index 000000000..d1991d890 --- /dev/null +++ b/mermaid/src/test/java/io/serverlessworkflow/mermaid/ClasspathYamlFinder.java @@ -0,0 +1,127 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed 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 io.serverlessworkflow.mermaid; + +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.JarURLConnection; +import java.net.URL; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.*; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public final class ClasspathYamlFinder { + private ClasspathYamlFinder() {} + + public static List listYamlResources(String base) throws IOException { + String prefix = normalizeBase(base); + ClassLoader cl = Thread.currentThread().getContextClassLoader(); + + // Try the requested base (as a directory) first; if not found, fall back to root + List bases = Collections.list(cl.getResources(prefix.isEmpty() ? "" : prefix + "/")); + if (bases.isEmpty() && !prefix.isEmpty()) { + bases = Collections.list(cl.getResources("")); // fallback + } + + Set results = new LinkedHashSet<>(); + for (URL url : bases) { + switch (url.getProtocol()) { + case "file" -> results.addAll(scanFileUrl(url, prefix)); + case "jar" -> results.addAll(scanJarUrl(url)); + default -> { + /* ignore */ + } + } + } + return results.stream().sorted().collect(Collectors.toList()); + } + + private static String normalizeBase(String base) { + if (base == null) return ""; + String b = base.replace('\\', '/'); + if (b.startsWith("/")) b = b.substring(1); + while (b.endsWith("/")) b = b.substring(0, b.length() - 1); + return b; + } + + private static Collection scanFileUrl(URL url, String prefix) { + try { + Path root = Paths.get(URLDecoder.decode(url.getPath(), StandardCharsets.UTF_8)); + if (!Files.exists(root)) return List.of(); + + // If we resolved exactly "//", we should prepend "prefix/" + // to the relativized filenames to mirror the JAR behaviour. + String rootStr = root.normalize().toString().replace('\\', '/'); + boolean rootIsPrefixDir = !prefix.isEmpty() && rootStr.endsWith("/" + prefix); + + try (Stream s = Files.walk(root)) { + return s.filter(Files::isRegularFile) + .filter( + p -> { + String name = p.getFileName().toString().toLowerCase(Locale.ROOT); + return name.endsWith(".yaml") || name.endsWith(".yml"); + }) + .map( + p -> { + String rel = root.relativize(p).toString().replace(File.separatorChar, '/'); + // When scanning the specific prefix directory, add "prefix/" so callers get + // paths relative to the classpath root, e.g. "workflows-samples/foo.yaml". + return rootIsPrefixDir ? (prefix + "/" + rel) : rel; + }) + .collect(Collectors.toCollection(LinkedHashSet::new)); + } + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private static Collection scanJarUrl(URL url) { + try { + JarURLConnection conn = (JarURLConnection) url.openConnection(); + try (JarFile jar = conn.getJarFile()) { + String dir = ensureDirPrefix(conn.getEntryName()); + List out = new ArrayList<>(); + Enumeration entries = jar.entries(); + while (entries.hasMoreElements()) { + JarEntry je = entries.nextElement(); + if (je.isDirectory()) continue; + String name = je.getName(); + if (!name.startsWith(dir)) continue; + String lower = name.toLowerCase(Locale.ROOT); + if (lower.endsWith(".yaml") || lower.endsWith(".yml")) { + out.add(name); + } + } + return out; + } + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private static String ensureDirPrefix(String entryName) { + if (entryName == null) return ""; + String e = entryName; + if (!e.isEmpty() && !e.endsWith("/")) e = e + "/"; + return e; + } +} diff --git a/mermaid/src/test/java/io/serverlessworkflow/mermaid/MermaidSmokeTest.java b/mermaid/src/test/java/io/serverlessworkflow/mermaid/MermaidSmokeTest.java new file mode 100644 index 000000000..e73b1d1bd --- /dev/null +++ b/mermaid/src/test/java/io/serverlessworkflow/mermaid/MermaidSmokeTest.java @@ -0,0 +1,51 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed 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 io.serverlessworkflow.mermaid; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +class MermaidSmokeTest { + + private static final String BASE = "workflows-samples"; // folder on test classpath + + static java.util.stream.Stream yamlSamples() { + try { + // First try the folder you expect, then rely on the fallback baked into the finder + var list = ClasspathYamlFinder.listYamlResources(BASE); + if (list.isEmpty()) { + throw new IllegalStateException( + """ + No YAML resources found on the test classpath. + - Is serverlessworkflow-impl-test built and its *-tests.jar on the test classpath? + - Are YAMLs under src/test/resources in that module? + - Path inside JAR may differ from '/'. + """); + } + return list.stream(); + } catch (java.io.IOException e) { + throw new java.io.UncheckedIOException(e); + } + } + + @ParameterizedTest(name = "{index} => {0}") + @MethodSource("yamlSamples") + void rendersBasicMermaidStructure(String resourcePath) throws Exception { + var wf = io.serverlessworkflow.api.WorkflowReader.readWorkflowFromClasspath(resourcePath); + var mermaid = new io.serverlessworkflow.mermaid.Mermaid().from(wf); + org.assertj.core.api.Assertions.assertThat(mermaid).isNotBlank().contains("flowchart TD"); + } +} diff --git a/mermaid/src/test/java/io/serverlessworkflow/mermaid/MermaidTest.java b/mermaid/src/test/java/io/serverlessworkflow/mermaid/MermaidTest.java deleted file mode 100644 index 98177620e..000000000 --- a/mermaid/src/test/java/io/serverlessworkflow/mermaid/MermaidTest.java +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright 2020-Present The Serverless Workflow Specification Authors - * - * Licensed 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 io.serverlessworkflow.mermaid; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.io.IOException; -import org.junit.jupiter.api.Test; - -public class MermaidTest { - - @Test - public void basic_check_whenBasicFlow() throws IOException { - final String renderedWf = new Mermaid().from("samples/fork.yaml"); - - assertThat(renderedWf).isNotBlank(); - } -} diff --git a/mermaid/src/test/resources/samples/call-http-endpoint-interpolation.yaml b/mermaid/src/test/resources/samples/call-http-endpoint-interpolation.yaml deleted file mode 100644 index b72fa8804..000000000 --- a/mermaid/src/test/resources/samples/call-http-endpoint-interpolation.yaml +++ /dev/null @@ -1,13 +0,0 @@ -document: - dsl: '1.0.0' - namespace: examples - name: call-http-shorthand-endpoint - version: '0.1.0' -do: - - getPet: - call: http - with: - headers: - content-type: application/json - method: get - endpoint: ${ "https://petstore.swagger.io/v2/pet/\(.petId)" } diff --git a/mermaid/src/test/resources/samples/composite.yaml b/mermaid/src/test/resources/samples/composite.yaml deleted file mode 100644 index 6961b5787..000000000 --- a/mermaid/src/test/resources/samples/composite.yaml +++ /dev/null @@ -1,17 +0,0 @@ -document: - dsl: '1.0.0' - namespace: default - name: do - version: '1.0.0' -do: - - compositeExample: - do: - - setRed: - set: - colors: ${ .colors + ["red"] } - - setGreen: - set: - colors: ${ .colors + ["green"] } - - setBlue: - set: - colors: ${ .colors + ["blue"] } \ No newline at end of file diff --git a/mermaid/src/test/resources/samples/emit.yaml b/mermaid/src/test/resources/samples/emit.yaml deleted file mode 100644 index 82fe2823f..000000000 --- a/mermaid/src/test/resources/samples/emit.yaml +++ /dev/null @@ -1,19 +0,0 @@ -document: - dsl: '1.0.0' - namespace: test - name: emit - version: '0.1.0' -do: - - emitEvent: - emit: - event: - with: - source: https://petstore.com - type: com.petstore.order.placed.v1 - data: - client: - firstName: Cruella - lastName: de Vil - items: - - breed: dalmatian - quantity: 101 \ No newline at end of file diff --git a/mermaid/src/test/resources/samples/fork.yaml b/mermaid/src/test/resources/samples/fork.yaml deleted file mode 100644 index 419346242..000000000 --- a/mermaid/src/test/resources/samples/fork.yaml +++ /dev/null @@ -1,26 +0,0 @@ -document: - dsl: '1.0.0' - namespace: test - name: fork-example - version: '0.1.0' -do: - - raiseAlarm: - fork: - compete: true - branches: - - callNurse: - call: http - with: - method: put - endpoint: https://fake-hospital.com/api/v3/alert/nurses - body: - patientId: ${ .patient.fullName } - room: ${ .room.number } - - callDoctor: - call: http - with: - method: put - endpoint: https://fake-hospital.com/api/v3/alert/doctor - body: - patientId: ${ .patient.fullName } - room: ${ .room.number } \ No newline at end of file diff --git a/mermaid/src/test/resources/samples/listen-to-any-forever-foreach.yaml b/mermaid/src/test/resources/samples/listen-to-any-forever-foreach.yaml deleted file mode 100644 index 53f93bc98..000000000 --- a/mermaid/src/test/resources/samples/listen-to-any-forever-foreach.yaml +++ /dev/null @@ -1,22 +0,0 @@ -document: - dsl: '1.0.0' - namespace: test - name: listen-to-any-while-foreach - version: '0.1.0' -do: - - listenToGossips: - listen: - to: - any: [] - until: '${ false }' - foreach: - item: event - at: i - do: - - postToChatApi: - call: http - with: - method: post - endpoint: https://fake-chat-api.com/room/{roomId} - body: - event: ${ $event } \ No newline at end of file diff --git a/mermaid/src/test/resources/samples/pet-store.yaml b/mermaid/src/test/resources/samples/pet-store.yaml deleted file mode 100644 index 425369f1b..000000000 --- a/mermaid/src/test/resources/samples/pet-store.yaml +++ /dev/null @@ -1,88 +0,0 @@ -document: - dsl: '1.0.0' - namespace: test - name: order-pet - version: '0.1.0' - title: Order Pet - 1.0.0 - summary: > - # Order Pet - 1.0.0 - ## Table of Contents - - [Description](#description) - - [Requirements](#requirements) - ## Description - A sample workflow used to process an hypothetic pet order using the [PetStore API](https://petstore.swagger.io/) - ## Requirements - ### Secrets - - my-oauth2-secret -use: - authentications: - petStoreOAuth2: - oauth2: - authority: https://petstore.swagger.io/.well-known/openid-configuration - grant: client_credentials - client: - id: workflow-runtime - secret: "**********" - scopes: [ api ] - audiences: [ runtime ] - extensions: - - externalLogging: - extend: all - before: - - sendLog: - call: http - with: - method: post - endpoint: https://fake.log.collector.com - body: - message: ${ "Executing task '\($task.reference)'..." } - after: - - sendLog: - call: http - with: - method: post - endpoint: https://fake.log.collector.com - body: - message: ${ "Executed task '\($task.reference)'..." } - functions: - getAvailablePets: - call: openapi - with: - document: - endpoint: https://petstore.swagger.io/v2/swagger.json - operationId: findByStatus - parameters: - status: available - secrets: - - my-oauth2-secret -do: - - getAvailablePets: - call: getAvailablePets - output: - as: "$input + { availablePets: [.[] | select(.category.name == \"dog\" and (.tags[] | .breed == $input.order.breed))] }" - - submitMatchesByMail: - call: http - with: - method: post - endpoint: - uri: https://fake.smtp.service.com/email/send - authentication: - use: petStoreOAuth2 - body: - from: noreply@fake.petstore.com - to: ${ .order.client.email } - subject: Candidates for Adoption - body: > - Hello ${ .order.client.preferredDisplayName }! - - Following your interest to adopt a dog, here is a list of candidates that you might be interested in: - - ${ .pets | map("-\(.name)") | join("\n") } - - Please do not hesistate to contact us at info@fake.petstore.com if your have questions. - - Hope to hear from you soon! - - ---------------------------------------------------------------------------------------------- - DO NOT REPLY - ---------------------------------------------------------------------------------------------- \ No newline at end of file diff --git a/mermaid/src/test/resources/samples/room-readings.yaml b/mermaid/src/test/resources/samples/room-readings.yaml deleted file mode 100644 index a41e5d538..000000000 --- a/mermaid/src/test/resources/samples/room-readings.yaml +++ /dev/null @@ -1,44 +0,0 @@ -document: - dsl: '1.0.0' - namespace: examples - name: accumulate-room-readings - version: '0.1.0' -do: - - consumeReading: - listen: - to: - all: - - with: - source: https://my.home.com/sensor - type: my.home.sensors.temperature - correlate: - roomId: - from: .roomid - - with: - source: https://my.home.com/sensor - type: my.home.sensors.humidity - correlate: - roomId: - from: .roomid - output: - as: .data.reading - - logReading: - for: - each: reading - in: .readings - do: - - callOrderService: - call: openapi - with: - document: - endpoint: http://myorg.io/ordersservices.json - operationId: logreading - - generateReport: - call: openapi - with: - document: - endpoint: http://myorg.io/ordersservices.json - operationId: produceReport -timeout: - after: - hours: 1 \ No newline at end of file diff --git a/mermaid/src/test/resources/samples/try-catch.yaml b/mermaid/src/test/resources/samples/try-catch.yaml deleted file mode 100644 index 56957fe48..000000000 --- a/mermaid/src/test/resources/samples/try-catch.yaml +++ /dev/null @@ -1,39 +0,0 @@ -document: - dsl: '1.0.0' - namespace: default - name: try-catch - version: '0.1.0' -do: - - tryGetPet: - try: - - getPet: - call: http - with: - method: get - endpoint: https://petstore.swagger.io/v2/pet/{petId} - catch: - errors: - with: - type: https://serverlessworkflow.io/spec/1.0.0/errors/communication - status: 404 - as: error - do: - - notifySupport: - emit: - event: - with: - source: https://petstore.swagger.io - type: io.swagger.petstore.events.pets.not-found.v1 - data: ${ $error } - - setError: - set: - error: $error - export: - as: '$context + { error: $error }' - - buyPet: - if: $context.error == null - call: http - with: - method: put - endpoint: https://petstore.swagger.io/v2/pet/{petId} - body: '${ . + { status: "sold" } }' \ No newline at end of file From 349f2e29526b2cf960305544484def6126f0fc16 Mon Sep 17 00:00:00 2001 From: Ricardo Zanini Date: Tue, 2 Sep 2025 14:40:38 -0400 Subject: [PATCH 3/3] Incorporate dmitrii's review Signed-off-by: Ricardo Zanini --- .../mermaid/ClasspathYamlFinder.java | 83 +++++++++---------- .../mermaid/MermaidSmokeTest.java | 37 +++++---- 2 files changed, 56 insertions(+), 64 deletions(-) diff --git a/mermaid/src/test/java/io/serverlessworkflow/mermaid/ClasspathYamlFinder.java b/mermaid/src/test/java/io/serverlessworkflow/mermaid/ClasspathYamlFinder.java index d1991d890..1eed70f52 100644 --- a/mermaid/src/test/java/io/serverlessworkflow/mermaid/ClasspathYamlFinder.java +++ b/mermaid/src/test/java/io/serverlessworkflow/mermaid/ClasspathYamlFinder.java @@ -17,7 +17,6 @@ import java.io.File; import java.io.IOException; -import java.io.UncheckedIOException; import java.net.JarURLConnection; import java.net.URL; import java.net.URLDecoder; @@ -63,58 +62,50 @@ private static String normalizeBase(String base) { return b; } - private static Collection scanFileUrl(URL url, String prefix) { - try { - Path root = Paths.get(URLDecoder.decode(url.getPath(), StandardCharsets.UTF_8)); - if (!Files.exists(root)) return List.of(); + private static Collection scanFileUrl(URL url, String prefix) throws IOException { + Path root = Paths.get(URLDecoder.decode(url.getPath(), StandardCharsets.UTF_8)); + if (!Files.exists(root)) return List.of(); - // If we resolved exactly "//", we should prepend "prefix/" - // to the relativized filenames to mirror the JAR behaviour. - String rootStr = root.normalize().toString().replace('\\', '/'); - boolean rootIsPrefixDir = !prefix.isEmpty() && rootStr.endsWith("/" + prefix); + // If we resolved exactly "//", we should prepend "prefix/" + // to the relativized filenames to mirror the JAR behaviour. + String rootStr = root.normalize().toString().replace('\\', '/'); + boolean rootIsPrefixDir = !prefix.isEmpty() && rootStr.endsWith("/" + prefix); - try (Stream s = Files.walk(root)) { - return s.filter(Files::isRegularFile) - .filter( - p -> { - String name = p.getFileName().toString().toLowerCase(Locale.ROOT); - return name.endsWith(".yaml") || name.endsWith(".yml"); - }) - .map( - p -> { - String rel = root.relativize(p).toString().replace(File.separatorChar, '/'); - // When scanning the specific prefix directory, add "prefix/" so callers get - // paths relative to the classpath root, e.g. "workflows-samples/foo.yaml". - return rootIsPrefixDir ? (prefix + "/" + rel) : rel; - }) - .collect(Collectors.toCollection(LinkedHashSet::new)); - } - } catch (IOException e) { - throw new UncheckedIOException(e); + try (Stream s = Files.walk(root)) { + return s.filter(Files::isRegularFile) + .filter( + p -> { + String name = p.getFileName().toString().toLowerCase(Locale.ROOT); + return name.endsWith(".yaml") || name.endsWith(".yml"); + }) + .map( + p -> { + String rel = root.relativize(p).toString().replace(File.separatorChar, '/'); + // When scanning the specific prefix directory, add "prefix/" so callers get + // paths relative to the classpath root, e.g. "workflows-samples/foo.yaml". + return rootIsPrefixDir ? (prefix + "/" + rel) : rel; + }) + .collect(Collectors.toCollection(LinkedHashSet::new)); } } - private static Collection scanJarUrl(URL url) { - try { - JarURLConnection conn = (JarURLConnection) url.openConnection(); - try (JarFile jar = conn.getJarFile()) { - String dir = ensureDirPrefix(conn.getEntryName()); - List out = new ArrayList<>(); - Enumeration entries = jar.entries(); - while (entries.hasMoreElements()) { - JarEntry je = entries.nextElement(); - if (je.isDirectory()) continue; - String name = je.getName(); - if (!name.startsWith(dir)) continue; - String lower = name.toLowerCase(Locale.ROOT); - if (lower.endsWith(".yaml") || lower.endsWith(".yml")) { - out.add(name); - } + private static Collection scanJarUrl(URL url) throws IOException { + JarURLConnection conn = (JarURLConnection) url.openConnection(); + try (JarFile jar = conn.getJarFile()) { + String dir = ensureDirPrefix(conn.getEntryName()); + List out = new ArrayList<>(); + Enumeration entries = jar.entries(); + while (entries.hasMoreElements()) { + JarEntry je = entries.nextElement(); + if (je.isDirectory()) continue; + String name = je.getName(); + if (!name.startsWith(dir)) continue; + String lower = name.toLowerCase(Locale.ROOT); + if (lower.endsWith(".yaml") || lower.endsWith(".yml")) { + out.add(name); } - return out; } - } catch (IOException e) { - throw new UncheckedIOException(e); + return out; } } diff --git a/mermaid/src/test/java/io/serverlessworkflow/mermaid/MermaidSmokeTest.java b/mermaid/src/test/java/io/serverlessworkflow/mermaid/MermaidSmokeTest.java index e73b1d1bd..f616f32c5 100644 --- a/mermaid/src/test/java/io/serverlessworkflow/mermaid/MermaidSmokeTest.java +++ b/mermaid/src/test/java/io/serverlessworkflow/mermaid/MermaidSmokeTest.java @@ -15,6 +15,11 @@ */ package io.serverlessworkflow.mermaid; +import static org.assertj.core.api.Assertions.assertThat; + +import io.serverlessworkflow.api.WorkflowReader; +import java.io.IOException; +import java.util.stream.Stream; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; @@ -22,30 +27,26 @@ class MermaidSmokeTest { private static final String BASE = "workflows-samples"; // folder on test classpath - static java.util.stream.Stream yamlSamples() { - try { - // First try the folder you expect, then rely on the fallback baked into the finder - var list = ClasspathYamlFinder.listYamlResources(BASE); - if (list.isEmpty()) { - throw new IllegalStateException( - """ - No YAML resources found on the test classpath. - - Is serverlessworkflow-impl-test built and its *-tests.jar on the test classpath? - - Are YAMLs under src/test/resources in that module? - - Path inside JAR may differ from '/'. - """); - } - return list.stream(); - } catch (java.io.IOException e) { - throw new java.io.UncheckedIOException(e); + static Stream yamlSamples() throws IOException { + // First try the folder you expect, then rely on the fallback baked into the finder + var list = ClasspathYamlFinder.listYamlResources(BASE); + if (list.isEmpty()) { + throw new IllegalStateException( + """ + No YAML resources found on the test classpath. + - Is serverlessworkflow-impl-test built and its *-tests.jar on the test classpath? + - Are YAMLs under src/test/resources in that module? + - Path inside JAR may differ from '/'. + """); } + return list.stream(); } @ParameterizedTest(name = "{index} => {0}") @MethodSource("yamlSamples") void rendersBasicMermaidStructure(String resourcePath) throws Exception { - var wf = io.serverlessworkflow.api.WorkflowReader.readWorkflowFromClasspath(resourcePath); + var wf = WorkflowReader.readWorkflowFromClasspath(resourcePath); var mermaid = new io.serverlessworkflow.mermaid.Mermaid().from(wf); - org.assertj.core.api.Assertions.assertThat(mermaid).isNotBlank().contains("flowchart TD"); + assertThat(mermaid).isNotBlank().contains("flowchart TD"); } }