diff --git a/README.md b/README.md index d12dad2a..7958088f 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,26 @@ ![Node CI](https://github.com/serverlessworkflow/sdk-typescript/workflows/Node%20CI/badge.svg) [![Gitpod ready-to-code](https://img.shields.io/badge/Gitpod-ready--to--code-blue?logo=gitpod)](https://gitpod.io/#https://github.com/serverlessworkflow/sdk-typescript) +- [Serverless Workflow Specification - TypeScript SDK](#serverless-workflow-specification---typescript-sdk) + - [Status](#status) + - [SDK Structure](#sdk-structure) + - [Types and Interfaces](#types-and-interfaces) + - [Classes](#classes) + - [Fluent Builders](#fluent-builders) + - [Validation Function](#validation-function) + - [Other tools](#other-tools) + - [Getting Started](#getting-started) + - [Installation](#installation) + - [Usage](#usage) + - [Create a Workflow Definition from YAML or JSON](#create-a-workflow-definition-from-yaml-or-json) + - [Create a Workflow Definition by Casting an Object](#create-a-workflow-definition-by-casting-an-object) + - [Create a Workflow Definition Using a Class Constructor](#create-a-workflow-definition-using-a-class-constructor) + - [Create a Workflow Definition Using the Builder API](#create-a-workflow-definition-using-the-builder-api) + - [Serialize a Workflow Definition to YAML or JSON](#serialize-a-workflow-definition-to-yaml-or-json) + - [Validate Workflow Definitions](#validate-workflow-definitions) + - [Generate a directed graph](#generate-a-directed-graph) + - [Generate a MermaidJS flowchart](#generate-a-mermaidjs-flowchart) + - [Building Locally](#building-locally) + # Serverless Workflow Specification - TypeScript SDK This SDK provides a TypeScript API for working with the [Serverless Workflow Specification](https://github.com/serverlessworkflow/specification). @@ -14,7 +35,7 @@ The npm [`@serverlessworkflow/sdk`](https://www.npmjs.com/package/@serverlesswor | Latest Releases | Conformance to Spec Version | | :---: | :---: | -| [v1.0.0.\*](https://github.com/serverlessworkflow/sdk-typescript/releases/) | [v1.0.0](https://github.com/serverlessworkflow/specification) | +| [v1.0.\*](https://github.com/serverlessworkflow/sdk-typescript/releases/) | [v1.0.0](https://github.com/serverlessworkflow/specification) | > [!WARNING] > Previous versions of the SDK were published with a typo in the scope: @@ -56,6 +77,9 @@ The SDK includes a validation function to check if objects conform to the expect The `validate` function is directly exported and can be used as `validate('Workflow', workflowObject)`. +### Other Tools +The SDK also ships tools to build directed graph and MermaidJS flowcharts from a workflow. + ## Getting Started ### Installation @@ -86,7 +110,7 @@ do: set: variable: 'my first workflow' `; -const workflowDefinition = Classes.Workflow.deserialize(text); +const workflow = Classes.Workflow.deserialize(text); ``` #### Create a Workflow Definition by Casting an Object @@ -96,7 +120,7 @@ You can type-cast an object to match the structure of a workflow definition: import { Classes, Specification, validate } from '@serverlessworkflow/sdk'; // Simply cast an object: -const workflowDefinition = { +const workflow = { document: { dsl: '1.0.0', name: 'test', @@ -116,9 +140,9 @@ const workflowDefinition = { // Validate it try { - validate('Workflow', workflowDefinition); + validate('Workflow', workflow); // Serialize it - const definitionTxt = Classes.Workflow.serialize(workflowDefinition); + const definitionTxt = Classes.Workflow.serialize(workflow); } catch (ex) { // Invalid workflow definition @@ -132,7 +156,7 @@ You can create a workflow definition by calling a constructor: import { Classes, validate } from '@serverlessworkflow/sdk'; // Simply use the constructor -const workflowDefinition = new Classes.Workflow({ +const workflow = new Classes.Workflow({ document: { dsl: '1.0.0', name: 'test', @@ -149,7 +173,7 @@ const workflowDefinition = new Classes.Workflow({ }, */], }); -workflowDefinition.do.push({ +workflow.do.push({ step1: new Classes.SetTask({ set: { variable: 'my first workflow', @@ -159,9 +183,9 @@ workflowDefinition.do.push({ // Validate it try { - workflowDefinition.validate(); + workflow.validate(); // Serialize it - const definitionTxt = workflowDefinition.serialize(); + const definitionTxt = workflow.serialize(); } catch (ex) { // Invalid workflow definition @@ -174,7 +198,7 @@ You can use the fluent API to build a validated and normalized workflow definiti ```typescript import { documentBuilder, setTaskBuilder, taskListBuilder, workflowBuilder } from '@serverlessworkflow/sdk'; -const workflowDefinition = workflowBuilder(/*workflowDefinitionObject*/) +const workflow = workflowBuilder(/*workflowObject*/) .document( documentBuilder() .dsl('1.0.0') @@ -206,12 +230,12 @@ You can serialize a workflow definition either by using its `serialize` method i ```typescript import { Classes } from '@serverlessworkflow/sdk'; -// const workflowDefinition = ; -if (workflowDefinition instanceof Classes.Workflow) { - const yaml = workflowDefinition.serialize(/*'yaml' | 'json' */); +// const workflow = ; +if (workflow instanceof Classes.Workflow) { + const yaml = workflow.serialize(/*'yaml' | 'json' */); } else { - const json = Classes.Workflow.serialize(workflowDefinition, 'json'); + const json = Classes.Workflow.serialize(workflow, 'json'); } ``` > [!NOTE] @@ -223,13 +247,13 @@ Validation can be achieved in two ways: via the `validate` function or the insta ```typescript import { Classes, validate } from '@serverlessworkflow/sdk'; -// const workflowDefinition = ; +const workflow = /* */; try { - if (workflowDefinition instanceof Classes.Workflow) { - workflowDefinition.validate(); + if (workflow instanceof Classes.Workflow) { + workflow.validate(); } else { - validate('Workflow', workflowDefinition); + validate('Workflow', workflow); } } catch (ex) { @@ -237,6 +261,100 @@ catch (ex) { } ``` +#### Generate a directed graph +A [directed graph](https://en.wikipedia.org/wiki/Directed_graph) of a workflow can be generated using the `buildGraph` function, or alternatives: +- Workflow instance `.toGraph();` +- Static `Classes.Workflow.toGraph(workflow)` + +```typescript +import { buildGraph } from '@serverlessworkflow/sdk'; + +const workflow = { + document: { + dsl: '1.0.0', + name: 'using-plain-object', + version: '1.0.0', + namespace: 'default', + }, + do: [ + { + step1: { + set: { + variable: 'my first workflow', + }, + }, + }, + ], +}; +const graph = buildGraph(workflow); +// const workflow = new Classes.Workflow({...}); const graph = workflow.toGraph(); +// const graph = Classes.Workflow.toGraph(workflow); +/*{ + id: 'root', + type: 'root', + label: undefined, + parent: null, + nodes: [...], // length 3 - root entry node, step1 node, root exit node + edges: [...], // length 2 - entry to step1, step1 to exit + entryNode: {...}, // root entry node + exitNode: {...} // root exit node +}*/ +``` + +#### Generate a MermaidJS flowchart +Generating a [MermaidJS](https://mermaid.js.org/) flowchart can be achieved in two ways: using the `convertToMermaidCode`, the legacy `MermaidDiagram` class, or alternatives: +- Workflow instance `.toMermaidCode();` +- Static `Classes.Workflow.toMermaidCode(workflow)` + +```typescript +import { convertToMermaidCode, MermaidDiagram } from '@serverlessworkflow/sdk'; + +const workflow = { + document: { + dsl: '1.0.0', + name: 'using-plain-object', + version: '1.0.0', + namespace: 'default', + }, + do: [ + { + step1: { + set: { + variable: 'my first workflow', + }, + }, + }, + ], +}; +const mermaidCode = convertToMermaidCode(workflow) /* or */; +// const mermaidCode = new MermaidDiagram(workflow).sourceCode(); +// const workflow = new Classes.Workflow({...}); const mermaidCode = workflow.toMermaidCode(); +// const mermaidCode = Classes.Workflow.toMermaidCode(workflow); +/* +flowchart TD + root-entry-node(( )) + root-exit-node((( ))) + /do/0/step1["step1"] + /do/0/step1 --> root-exit-node + root-entry-node --> /do/0/step1 + + +classDef hidden display: none; +*/ +``` + +```mermaid +flowchart TD + root-entry-node(( )) + root-exit-node((( ))) + /do/0/step1["step1"] + /do/0/step1 --> root-exit-node + root-entry-node --> /do/0/step1 + + +classDef hidden display: none; +``` + ### Building Locally To build the project and run tests locally, use the following commands: diff --git a/examples/browser/mermaid.html b/examples/browser/mermaid.html new file mode 100644 index 00000000..adfa781d --- /dev/null +++ b/examples/browser/mermaid.html @@ -0,0 +1,131 @@ + + + + + + Serveless Workflow + + + + + +

YAML or JSON:

+ +
+

+  
+  
+
+
+
\ No newline at end of file
diff --git a/package.json b/package.json
index 591ffaf9..8c5380a0 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@serverlessworkflow/sdk",
-  "version": "1.0.0",
+  "version": "1.0.1",
   "schemaVersion": "1.0.0",
   "description": "Typescript SDK for Serverless Workflow Specification",
   "main": "umd/index.umd.min.js",
diff --git a/src/lib/generated/classes/workflow.ts b/src/lib/generated/classes/workflow.ts
index f1c1b8bd..e7308cca 100644
--- a/src/lib/generated/classes/workflow.ts
+++ b/src/lib/generated/classes/workflow.ts
@@ -33,6 +33,8 @@ import { getLifecycleHooks } from '../../lifecycle-hooks';
 import { validate } from '../../validation';
 import { isObject } from '../../utils';
 import * as yaml from 'js-yaml';
+import { buildGraph, Graph } from '../../graph-builder';
+import { convertToMermaidCode } from '../../mermaid-converter';
 
 /**
  * Represents the intersection between the Workflow class and type
@@ -112,6 +114,14 @@ export class Workflow extends ObjectHydrator {
     return yaml.dump(normalized);
   }
 
+  static toGraph(model: Partial): Graph {
+    return buildGraph(model as unknown as WorkflowIntersection);
+  }
+
+  static toMermaidCode(model: Partial): string {
+    return convertToMermaidCode(model as unknown as WorkflowIntersection);
+  }
+
   /**
    * Serializes the workflow to YAML or JSON
    * @param format The format, 'yaml' or 'json', default is 'yaml'
@@ -121,6 +131,22 @@ export class Workflow extends ObjectHydrator {
   serialize(format: 'yaml' | 'json' = 'yaml', normalize: boolean = true): string {
     return Workflow.serialize(this as unknown as WorkflowIntersection, format, normalize);
   }
+
+  /**
+   * Creates a directed graph representation of the workflow
+   * @returns A directed graph of the workflow
+   */
+  toGraph(): Graph {
+    return Workflow.toGraph(this as unknown as WorkflowIntersection);
+  }
+
+  /**
+   * Generates the MermaidJS code corresponding to the workflow
+   * @returns The MermaidJS code
+   */
+  toMermaidCode(): string {
+    return Workflow.toMermaidCode(this as unknown as WorkflowIntersection);
+  }
 }
 
 export const _Workflow = Workflow as WorkflowConstructor & {
@@ -139,4 +165,18 @@ export const _Workflow = Workflow as WorkflowConstructor & {
    * @returns A string representation of the workflow
    */
   serialize(workflow: Partial, format?: 'yaml' | 'json', normalize?: boolean): string;
+
+  /**
+   * Creates a directed graph representation of the provided workflow
+   * @param workflow The workflow to convert
+   * @returns A directed graph of the provided workflow
+   */
+  toGraph(workflow: Partial): Graph;
+
+  /**
+   * Generates the MermaidJS code corresponding to the provided workflow
+   * @param workflow The workflow to convert
+   * @returns The MermaidJS code
+   */
+  toMermaidCode(workflow: Partial): string;
 };
diff --git a/src/lib/graph-builder.ts b/src/lib/graph-builder.ts
new file mode 100644
index 00000000..ef2d6c0f
--- /dev/null
+++ b/src/lib/graph-builder.ts
@@ -0,0 +1,584 @@
+import {
+  CallTask,
+  DoTask,
+  EmitTask,
+  ForkTask,
+  ForTask,
+  ListenTask,
+  RaiseTask,
+  RunTask,
+  SetTask,
+  SwitchTask,
+  Task,
+  TaskItem,
+  TryTask,
+  WaitTask,
+  Workflow,
+} from './generated/definitions/specification';
+
+const entrySuffix = '-entry-node';
+const exitSuffix = '-exit-node';
+
+const rooId = 'root';
+
+const doReference = '/do';
+const forReference = '/for';
+const catchReference = '/catch';
+const branchReference = '/fork/branches';
+const tryReference = '/try';
+
+/**
+ * Represents a generic within a graph.
+ * This serves as a base type for nodes, edges, and graphs.
+ */
+export type GraphElement = {
+  /** A unique identifier for this graph element. */
+  id: string;
+  /** An optional label to provide additional context or naming. */
+  label?: string | null;
+};
+
+/**
+ * Enumeration of possible node types in a graph.
+ */
+export enum GraphNodeType {
+  Root = 'root',
+  Start = 'start',
+  End = 'end',
+  Entry = 'entry',
+  Exit = 'exit',
+  Call = 'call',
+  Catch = 'catch',
+  Do = 'do',
+  Emit = 'emit',
+  For = 'for',
+  Fork = 'fork',
+  Listen = 'listen',
+  Raise = 'raise',
+  Run = 'run',
+  Set = 'set',
+  Switch = 'switch',
+  Try = 'try',
+  TryCatch = 'try-catch',
+  Wait = 'wait',
+}
+
+/**
+ * Represents a node within the graph.
+ */
+export type GraphNode = GraphElement & {
+  /** The type of the node. */
+  type: GraphNodeType;
+};
+
+/**
+ * Represents a directed edge connecting two nodes in the graph.
+ */
+export type GraphEdge = GraphElement & {
+  /** The unique identifier of the node where the edge originates. */
+  sourceId: string;
+  /** The unique identifier of the node where the edge terminates. */
+  destinationId: string;
+};
+
+/**
+ * Represents a graph or a subgraph
+ */
+export type Graph = GraphNode & {
+  /** The parent graph if this is a subgraph, otherwise null. */
+  parent?: Graph | null;
+  /** A collection of nodes that belong to this graph. */
+  nodes: GraphNode[];
+  /** A collection of edges that define relationships between nodes. */
+  edges: GraphEdge[];
+  /** The entry node of the graph. */
+  entryNode: GraphNode;
+  /** The exit node of the graph. */
+  exitNode: GraphNode;
+};
+
+/**
+ * Context information used when processing tasks in a workflow graph.
+ */
+type TaskContext = {
+  graph: Graph;
+  reference: string;
+  taskList: Map;
+  taskName?: string | null;
+  taskReference: string;
+};
+
+/**
+ * Identity information for a transition between tasks.
+ */
+type TransitionInfo = {
+  /** Name of the task to transition to. */
+  name: string;
+  /** Index position in the task list. */
+  index: number;
+  /** Optional reference to the associated task. */
+  task?: Task;
+  /** Optional label of the transition */
+  label?: string;
+};
+
+/**
+ * Enumeration of possible workflow flow directives.
+ */
+enum FlowDirective {
+  Exit = 'exit',
+  End = 'end',
+  Continue = 'continue',
+}
+
+/**
+ * Converts an array of TaskItem objects into a Map for easy lookup.
+ *
+ * @param tasksList An array of TaskItem objects.
+ * @returns A map where keys are task names and values are Task objects.
+ */
+function mapTasks(tasksList: TaskItem[] | null | undefined): Map {
+  return (tasksList || []).reduce((acc, item) => {
+    const [key, task] = Object.entries(item)[0];
+    acc.set(key, task);
+    return acc;
+  }, new Map());
+}
+
+/**
+ * Initializes a graph with default entry and exit nodes.
+ *
+ * @param type The type of the graph node.
+ * @param id Unique identifier for the graph.
+ * @param label Optional label for the graph.
+ * @param parent Optional parent graph if this is a subgraph.
+ * @returns A newly created Graph instance.
+ */
+function initGraph(
+  type: GraphNodeType,
+  id: string = rooId,
+  label: string | null | undefined = undefined,
+  parent: Graph | null | undefined = undefined,
+): Graph {
+  const entryNode: GraphNode = {
+    type: id === rooId ? GraphNodeType.Start : GraphNodeType.Entry,
+    id: `${id}${entrySuffix}`,
+  };
+  const exitNode: GraphNode = {
+    type: id === rooId ? GraphNodeType.End : GraphNodeType.Exit,
+    id: `${id}${exitSuffix}`,
+  };
+  const graph = {
+    id,
+    label,
+    type,
+    parent,
+    entryNode,
+    exitNode,
+    nodes: [entryNode, exitNode],
+    edges: [],
+  };
+  if (parent) parent.nodes.push(graph);
+  return graph;
+}
+
+/**
+ * Constructs a graph representation based on the given workflow.
+ *
+ * @param workflow The workflow to be converted into a graph structure.
+ * @returns A graph representation of the workflow.
+ */
+export function buildGraph(workflow: Workflow): Graph {
+  const graph = initGraph(GraphNodeType.Root);
+  buildTransitions(graph.entryNode, {
+    graph,
+    reference: doReference,
+    taskList: mapTasks(workflow.do),
+    taskReference: doReference,
+  });
+  return graph;
+}
+
+/**
+ * Gets the next task to be executed in the workflow
+ * @param tasksList The list of task to resolve the next task from
+ * @param taskName The current task name, if any
+ * @param transition A specific transition, if any
+ * @returns
+ */
+function getNextTask(
+  tasksList: Map,
+  taskName: string | null | undefined = undefined,
+  transition: string | null | undefined = undefined,
+): TransitionInfo {
+  if (!tasksList?.size) throw new Error('The task list cannot be empty. No tasks list to get the next task from.');
+  const currentTask: Task | undefined = tasksList.get(taskName || '');
+  transition = transition || currentTask?.then || '';
+  if (transition == FlowDirective.End || transition == FlowDirective.Exit) {
+    return {
+      name: transition,
+      index: -1,
+    };
+  }
+  let index: number = 0;
+  if (transition && transition != FlowDirective.Continue) {
+    index = Array.from(tasksList.keys()).indexOf(transition);
+  } else if (currentTask) {
+    index = Array.from(tasksList.values()).indexOf(currentTask) + 1;
+    if (index >= tasksList.size) {
+      return {
+        name: FlowDirective.End,
+        index: -1,
+      };
+    }
+  }
+  const taskEntry = Array.from(tasksList.entries())[index];
+  return {
+    index,
+    name: taskEntry[0],
+    task: taskEntry[1],
+  };
+}
+
+/**
+ * Builds the provided transition from the source node
+ * @param sourceNode The node to build the transition from
+ * @param transition The transition to follow
+ * @param context The context in which the transition is built
+ */
+function buildTransition(sourceNode: GraphNode | Graph, transition: TransitionInfo, context: TaskContext) {
+  const exitAnchor = (sourceNode as Graph).exitNode || sourceNode;
+  if (transition.index != -1) {
+    const destinationNode = buildTaskNode({
+      ...context,
+      taskReference: `${context.reference}/${transition.index}/${transition.name}`,
+      taskName: transition.name,
+    });
+    buildEdge(context.graph, exitAnchor, (destinationNode as Graph).entryNode || destinationNode, transition.label);
+  } else if (transition.name === FlowDirective.Exit) {
+    buildEdge(context.graph, exitAnchor, context.graph.exitNode, transition.label);
+  } else if (transition.name === FlowDirective.End) {
+    buildEdge(context.graph, exitAnchor, context.graph.exitNode, transition.label);
+  } else throw new Error('Invalid transition');
+}
+
+/**
+ * Builds all the possible transitions from the provided node in the provided context
+ * @param sourceNode The node to build the transitions from
+ * @param context The context in which the transitions are built
+ */
+function buildTransitions(sourceNode: GraphNode | Graph, context: TaskContext) {
+  const transitions: TransitionInfo[] = [];
+  let nextTransition = getNextTask(context.taskList, context.taskName);
+  transitions.push(nextTransition);
+  while (nextTransition?.task?.if) {
+    nextTransition.label = nextTransition?.task?.if;
+    nextTransition = getNextTask(context.taskList, nextTransition.name, FlowDirective.Continue);
+    transitions.push(nextTransition);
+  }
+  transitions
+    .filter(
+      (transition, index) =>
+        transitions.findIndex(
+          (t) => t.index === transition.index && t.name === transition.name && t.task === transition.task,
+        ) === index,
+    )
+    .forEach((transition) => buildTransition(sourceNode, transition, context));
+}
+
+/**
+ * Builds a graph representation of a task
+ * @param context The context to build the graph/node for
+ * @returns A graph or node for the provided context
+ */
+function buildTaskNode(context: TaskContext): GraphNode | Graph {
+  const task = context.taskList.get(context.taskName!);
+  if (!task) throw new Error(`Unabled to find the task '${context.taskName}' in the current context`);
+  if (task.call) return buildCallTaskNode(task, context);
+  if (task.catch) return buildTryCatchTaskNode(task, context);
+  if (task.emit) return buildEmitTaskNode(task, context);
+  if (task.for) return buildForTaskNode(task, context);
+  if (task.fork) return buildForkTaskNode(task, context);
+  if (task.listen) return buildListenTaskNode(task, context);
+  if (task.raise) return buildRaiseTaskNode(task, context);
+  if (task.run) return buildRunTaskNode(task, context);
+  if (task.set) return buildSetTaskNode(task, context);
+  if (task.switch) return buildSwitchTaskNode(task, context);
+  if (task.wait) return buildWaitTaskNode(task, context);
+  if (task.do) return buildDoTaskNode(task, context);
+  throw new Error(`Unable to defined task type of task named '${context.taskName}'`);
+}
+
+/**
+ * Builds a graph node with the provided type and context
+ * @param type The type of the node
+ * @param context The context to build the graph node for
+ * @returns A graph node for the provided context
+ */
+function buildGenericTaskNode(type: GraphNodeType, context: TaskContext): GraphNode {
+  const node: GraphNode = {
+    type,
+    id: context.taskReference,
+    label: context.taskName,
+  };
+  context.graph.nodes.push(node);
+  buildTransitions(node, context);
+  return node;
+}
+
+/**
+ * Builds a graph node for the provided call task
+ * @param task The task to build the graph node for
+ * @param context The context to build the graph node for
+ * @returns A graph node for the provided task
+ */
+function buildCallTaskNode(task: CallTask, context: TaskContext): GraphNode {
+  const node = buildGenericTaskNode(GraphNodeType.Call, context);
+  // TODO: add some details about the task?
+  return node;
+}
+
+/**
+ * Builds a graph for the provided do task
+ * @param task The task to build the graph for
+ * @param context The context to build the graph for
+ * @returns A graph for the provided task
+ */
+function buildDoTaskNode(task: DoTask, context: TaskContext): Graph {
+  const subgraph: Graph = initGraph(GraphNodeType.Do, context.taskReference, context.taskName, context.graph);
+  const doContext: TaskContext = {
+    ...context,
+    graph: subgraph,
+    reference: context.taskReference + doReference,
+    taskList: mapTasks(task.do),
+    taskName: null,
+  };
+  buildTransitions(subgraph.entryNode, doContext);
+  buildTransitions(subgraph, context);
+  return subgraph;
+}
+
+/**
+ * Builds a graph node for the provided emit task
+ * @param task The task to build the graph node for
+ * @param context The context to build the graph node for
+ * @returns A graph node for the provided task
+ */
+function buildEmitTaskNode(task: EmitTask, context: TaskContext): GraphNode {
+  const node = buildGenericTaskNode(GraphNodeType.Emit, context);
+  // TODO: add some details about the task?
+  return node;
+}
+
+/**
+ * Builds a graph for the provided for task
+ * @param task The task to build the graph for
+ * @param context The context to build the graph for
+ * @returns A graph for the provided task
+ */
+function buildForTaskNode(task: ForTask, context: TaskContext): Graph {
+  const subgraph: Graph = initGraph(GraphNodeType.For, context.taskReference, context.taskName, context.graph);
+  const forContext: TaskContext = {
+    ...context,
+    graph: subgraph,
+    reference: subgraph.id + forReference + doReference,
+    taskList: mapTasks(task.do),
+    taskName: null,
+  };
+  buildTransitions(subgraph.entryNode, forContext);
+  buildTransitions(subgraph, context);
+  return subgraph;
+}
+
+/**
+ * Builds a graph for the provided fork task
+ * @param task The task to build the graph for
+ * @param context The context to build the graph for
+ * @returns A graph for the provided task
+ */
+function buildForkTaskNode(task: ForkTask, context: TaskContext): Graph {
+  const subgraph: Graph = initGraph(GraphNodeType.Fork, context.taskReference, context.taskName, context.graph);
+  for (let i = 0, c = task.fork?.branches.length || 0; i < c; i++) {
+    const branchItem = task.fork?.branches[i];
+    if (!branchItem) continue;
+    const [branchName] = Object.entries(branchItem)[0];
+    const branchContext: TaskContext = {
+      ...context,
+      graph: subgraph,
+      reference: `${context.taskReference}${branchReference}`,
+      taskList: mapTasks([branchItem]),
+      taskReference: `${context.taskReference}${branchReference}/${i}/${branchName}`,
+      taskName: branchName,
+    };
+    const branchNode = buildTaskNode(branchContext);
+    buildEdge(subgraph, subgraph.entryNode, (branchNode as Graph).entryNode || branchNode);
+    buildEdge(subgraph, (branchNode as Graph).exitNode || branchNode, subgraph.exitNode);
+  }
+  buildTransitions(subgraph, context);
+  return subgraph;
+}
+
+/**
+ * Builds a graph node for the provided listen task
+ * @param task The task to build the graph node for
+ * @param context The context to build the graph node for
+ * @returns A graph node for the provided task
+ */
+function buildListenTaskNode(task: ListenTask, context: TaskContext): GraphNode {
+  const node = buildGenericTaskNode(GraphNodeType.Listen, context);
+  // TODO: add some details about the task?
+  return node;
+}
+
+/**
+ * Builds a graph node for the provided rasie task
+ * @param task The task to build the graph node for
+ * @param context The context to build the graph node for
+ * @returns A graph node for the provided task
+ */
+function buildRaiseTaskNode(task: RaiseTask, context: TaskContext): GraphNode {
+  const node = buildGenericTaskNode(GraphNodeType.Raise, context);
+  // TODO: add some details about the task?
+  return node;
+}
+
+/**
+ * Builds a graph node for the provided run task
+ * @param task The task to build the graph node for
+ * @param context The context to build the graph node for
+ * @returns A graph node for the provided task
+ */
+function buildRunTaskNode(task: RunTask, context: TaskContext): GraphNode {
+  const node = buildGenericTaskNode(GraphNodeType.Run, context);
+  // TODO: add some details about the task?
+  return node;
+}
+
+/**
+ * Builds a graph node for the provided set task
+ * @param task The task to build the graph node for
+ * @param context The context to build the graph node for
+ * @returns A graph node for the provided task
+ */
+function buildSetTaskNode(task: SetTask, context: TaskContext): GraphNode {
+  const node = buildGenericTaskNode(GraphNodeType.Set, context);
+  // TODO: add some details about the task?
+  return node;
+}
+
+/**
+ * Builds a graph node for the provided switch task
+ * @param task The task to build the graph node for
+ * @param context The context to build the graph node for
+ * @returns A graph node for the provided task
+ */
+function buildSwitchTaskNode(task: SwitchTask, context: TaskContext): GraphNode {
+  const node: GraphNode = buildGenericTaskNode(GraphNodeType.Switch, context);
+  let hasDefaultCase = false;
+  task.switch?.forEach((switchItem) => {
+    const [caseName, switchCase] = Object.entries(switchItem)[0];
+    if (!switchCase.when) hasDefaultCase = true;
+    const transition = getNextTask(context.taskList, context.taskName, switchCase.then);
+    transition.label = caseName;
+    buildTransition(node, transition, context);
+  });
+  if (!hasDefaultCase) {
+    buildTransitions(node, context);
+  }
+  return node;
+}
+
+/**
+ * Builds a graph for the provided try/catch task
+ * @param task The task to build the graph for
+ * @param context The context to build the graph for
+ * @returns A graph for the provided task
+ */
+function buildTryCatchTaskNode(task: TryTask, context: TaskContext): Graph {
+  const containerSubgraph: Graph = initGraph(
+    GraphNodeType.TryCatch,
+    context.taskReference,
+    context.taskName,
+    context.graph,
+  );
+  const trySubgraph: Graph = initGraph(
+    GraphNodeType.Try,
+    context.taskReference + tryReference,
+    context.taskName + ' (try)',
+    containerSubgraph,
+  );
+  buildEdge(containerSubgraph, containerSubgraph.entryNode, trySubgraph.entryNode);
+  const tryContext: TaskContext = {
+    ...context,
+    graph: trySubgraph,
+    reference: trySubgraph.id,
+    taskList: mapTasks(task.try),
+    taskName: null,
+  };
+  buildTransitions(trySubgraph.entryNode, tryContext);
+  if (!task.catch?.do?.length) {
+    const catchNode: GraphNode = {
+      type: GraphNodeType.Catch,
+      id: context.taskReference + catchReference,
+      label: context.taskName + ' (catch)',
+    };
+    containerSubgraph.nodes.push(catchNode);
+    buildEdge(containerSubgraph, trySubgraph.exitNode, catchNode);
+    buildEdge(containerSubgraph, catchNode, containerSubgraph.exitNode);
+  } else {
+    const catchSubgraph: Graph = initGraph(
+      GraphNodeType.Catch,
+      context.taskReference + catchReference + doReference,
+      context.taskName + ' (catch)',
+      containerSubgraph,
+    );
+    buildEdge(containerSubgraph, trySubgraph.exitNode, catchSubgraph.entryNode);
+    const catchContext: TaskContext = {
+      ...context,
+      graph: catchSubgraph,
+      reference: catchSubgraph.id,
+      taskList: mapTasks(task.catch.do),
+      taskName: null,
+    };
+    buildTransitions(catchSubgraph.entryNode, catchContext);
+    buildEdge(containerSubgraph, catchSubgraph.exitNode, containerSubgraph.exitNode);
+  }
+  buildTransitions(containerSubgraph, context);
+  return containerSubgraph;
+}
+
+/**
+ * Builds a graph node for the provided wait task
+ * @param task The task to build the graph node for
+ * @param context The context to build the graph node for
+ * @returns A graph node for the provided task
+ */
+function buildWaitTaskNode(task: WaitTask, context: TaskContext): GraphNode {
+  const node = buildGenericTaskNode(GraphNodeType.Wait, context);
+  // TODO: add some details about the task?
+  return node;
+}
+
+/**
+ * Builds an edge between two elements
+ * @param graph The graph element containing the nodes
+ * @param source The origin node
+ * @param destination The destination node
+ * @param label The edge label, if any
+ */
+function buildEdge(graph: Graph, source: GraphNode, destination: GraphNode, label: string = '') {
+  let edge = graph.edges.find((e) => e.sourceId === source.id && e.destinationId === destination.id);
+  if (edge) {
+    if (label) {
+      edge.label = edge.label + (edge.label ? ' / ' : '') + label;
+    }
+    return edge;
+  }
+  edge = {
+    label,
+    id: `${source.id}-${destination.id}${label ? `-${label}` : ''}`,
+    sourceId: source.id,
+    destinationId: destination.id,
+  };
+  graph.edges.push(edge);
+}
diff --git a/src/lib/mermaid-converter.ts b/src/lib/mermaid-converter.ts
new file mode 100644
index 00000000..5575eee5
--- /dev/null
+++ b/src/lib/mermaid-converter.ts
@@ -0,0 +1,100 @@
+import { Workflow } from './generated/definitions/specification';
+import { buildGraph, Graph, GraphEdge, GraphNode, GraphNodeType } from './graph-builder';
+
+/**
+ * Adds indentation to each line of the provided code
+ * @param code The code to indent
+ * @returns The indented code
+ */
+const indent = (code: string) =>
+  code
+    .split('\n')
+    .map((line) => `    ${line}`)
+    .join('\n');
+
+/**
+ * Converts a graph to Mermaid code
+ * @param graph The graph to convert
+ * @returns The converted graph
+ */
+function convertGraphToCode(graph: Graph): string {
+  const isRoot: boolean = graph.id === 'root';
+  const code = `${isRoot ? 'flowchart TD' : `subgraph ${graph.id} ["${graph.label || graph.id}"]`}
+${indent(graph.nodes.map((node) => convertNodeToCode(node)).join('\n'))}
+${indent(graph.edges.map((edge) => convertEdgeToCode(edge)).join('\n'))}
+${isRoot ? '' : 'end'}`;
+  return code;
+}
+
+/**
+ * Converts a node to Mermaid code
+ * @param node The node to convert
+ * @returns The converted node
+ */
+function convertNodeToCode(node: GraphNode | Graph): string {
+  let code = '';
+  if ((node as Graph).nodes?.length) {
+    code = convertGraphToCode(node as Graph);
+  } else {
+    code = node.id;
+    switch (node.type) {
+      case GraphNodeType.Entry:
+      case GraphNodeType.Exit:
+        code += ':::hidden';
+        break;
+      case GraphNodeType.Start:
+        code += '(( ))'; // alt '@{ shape: circle, label: " "}';
+        break;
+      case GraphNodeType.End:
+        code += '((( )))'; // alt '@{ shape: dbl-circ, label: " "}';
+        break;
+      default:
+        code += `["${node.label}"]`; // alt `@{ label: "${node.label}" }`
+    }
+  }
+  return code;
+}
+
+/**
+ * Converts an edge to Mermaid code
+ * @param edge The edge to convert
+ * @returns The converted edge
+ */
+function convertEdgeToCode(edge: GraphEdge): string {
+  const ignoreEndArrow =
+    !edge.destinationId.startsWith('root') &&
+    (edge.destinationId.endsWith('-entry-node') || edge.destinationId.endsWith('-exit-node'));
+  const code = `${edge.sourceId} ${edge.label ? `--"${edge.label}"` : ''}--${ignoreEndArrow ? '-' : '>'} ${edge.destinationId}`;
+  return code;
+}
+
+/**
+ * Converts the provided workflow to Mermaid code
+ * @param workflow The workflow to convert
+ * @returns The Mermaid diagram
+ */
+export function convertToMermaidCode(workflow: Workflow): string {
+  const graph = buildGraph(workflow);
+  return (
+    convertGraphToCode(graph) +
+    `
+
+classDef hidden display: none;`
+  );
+}
+
+/**
+ * Represents a Mermaid diagram generator for a given workflow.
+ * This class takes a workflow definition and converts it into a Mermaid.js-compatible diagram.
+ */
+export class MermaidDiagram {
+  constructor(private workflow: Workflow) {}
+
+  /**
+   * Generates the Mermaid code representation of the workflow.
+   * @returns The Mermaid diagram source code as a string.
+   */
+  sourceCode(): string {
+    return convertToMermaidCode(this.workflow);
+  }
+}
diff --git a/src/serverless-workflow-sdk.ts b/src/serverless-workflow-sdk.ts
index 35497532..746e9869 100644
--- a/src/serverless-workflow-sdk.ts
+++ b/src/serverless-workflow-sdk.ts
@@ -2,3 +2,5 @@ export * from './lib/generated/builders';
 export * from './lib/generated/classes';
 export * from './lib/generated/definitions';
 export * from './lib/validation';
+export * from './lib/graph-builder';
+export * from './lib/mermaid-converter';
diff --git a/tests/graph/graph.spec.ts b/tests/graph/graph.spec.ts
new file mode 100644
index 00000000..3418e6ae
--- /dev/null
+++ b/tests/graph/graph.spec.ts
@@ -0,0 +1,139 @@
+import { Specification } from '../../src';
+import { Classes } from '../../src/lib/generated/classes';
+import { buildGraph, Graph } from '../../src/lib/graph-builder';
+
+describe('Workflow to Graph', () => {
+  it('should build a graph for a workflow with a Set task, using the buildGraph function', () => {
+    const workflow = Classes.Workflow.deserialize(`
+document:
+  dsl: '1.0.0'
+  namespace: test
+  name: set
+  version: '0.1.0'
+do:
+  - initialize:
+      set:
+        foo: bar`);
+    const graph = buildGraph(workflow);
+    expect(graph).toBeDefined();
+    expect(graph.nodes.length).toBe(3); // start --> initialize --> end
+    expect(graph.edges.length).toBe(2);
+  });
+
+  it('should build a graph for a workflow with a Set task, using the instance method', () => {
+    const workflow = Classes.Workflow.deserialize(`
+document:
+  dsl: '1.0.0'
+  namespace: test
+  name: set
+  version: '0.1.0'
+do:
+  - initialize:
+      set:
+        foo: bar`);
+    const graph = workflow.toGraph();
+    expect(graph).toBeDefined();
+    expect(graph.nodes.length).toBe(3); // start --> initialize --> end
+    expect(graph.edges.length).toBe(2);
+  });
+
+  it('should build a graph for a workflow with a Set task, using the static method', () => {
+    const workflow = {
+      document: {
+        dsl: '1.0.0',
+        name: 'set',
+        version: '1.0.0',
+        namespace: 'test',
+      },
+      do: [
+        {
+          initialize: {
+            set: {
+              foo: 'bar',
+            },
+          },
+        },
+      ],
+    } as Specification.Workflow;
+    const graph = Classes.Workflow.toGraph(workflow);
+    expect(graph).toBeDefined();
+    expect(graph.nodes.length).toBe(3); // start --> initialize --> end
+    expect(graph.edges.length).toBe(2);
+  });
+
+  it('should build a graph for a workflow with multiple Set tasks', () => {
+    const workflow = Classes.Workflow.deserialize(`
+document:
+  dsl: '1.0.0'
+  namespace: test
+  name: set
+  version: '0.1.0'
+do:
+  - step1:
+      set:
+        foo: bar
+  - step2:
+      set:
+        foo2: bar
+  - step3:
+      set:
+        foo3: bar`);
+    const graph = buildGraph(workflow);
+    expect(graph).toBeDefined();
+    expect(graph.nodes.length).toBe(5); // start --> step1 --> step2 --> step3 --> end
+    expect(graph.edges.length).toBe(4);
+  });
+
+  it('should build a graph for a workflow with a task containing an If clause, producing an alternative edge', () => {
+    const workflow = Classes.Workflow.deserialize(`
+document:
+  dsl: '1.0.0'
+  namespace: test
+  name: set
+  version: '0.1.0'
+do:
+  - initialize:
+      if: \${ input.data == true }
+      set:
+        foo: bar`);
+    const graph = buildGraph(workflow);
+    expect(graph).toBeDefined();
+    expect(graph.nodes.length).toBe(3); // start --> initialize --> end
+    expect(graph.edges.length).toBe(3); //       ----------------->
+    expect(graph.edges.filter((e) => e.label === '${ input.data == true }').length).toBe(1);
+  });
+
+  it('should build a graph for a workflow with a For task, producing a subgraph', () => {
+    const workflow = Classes.Workflow.deserialize(`
+document:
+  dsl: '1.0.0'
+  namespace: test
+  name: for-example
+  version: '0.1.0'
+do:
+  - checkup:
+      for:
+        each: pet
+        in: .pets
+        at: index
+      while: .vet != null
+      do:
+        - waitForCheckup:
+            listen:
+              to:
+                one:
+                  with:
+                    type: com.fake.petclinic.pets.checkup.completed.v2
+            output:
+              as: '.pets + [{ "id": $pet.id }]'`);
+    const graph = buildGraph(workflow);
+    const forSubgraph = graph.nodes.find((node) => node.label === 'checkup') as Graph;
+    expect(graph).toBeDefined();
+    expect(graph.nodes.length).toBe(3); // start --> checkup --> end
+    expect(graph.edges.length).toBe(2);
+
+    expect(forSubgraph).toBeDefined();
+    expect(forSubgraph.nodes.length).toBe(3); // entry --> waitForCheckup --> exit
+    expect(forSubgraph.edges.length).toBe(2);
+  });
+});
diff --git a/tests/mermaid/mermaid.spec.ts b/tests/mermaid/mermaid.spec.ts
new file mode 100644
index 00000000..4c32ff3a
--- /dev/null
+++ b/tests/mermaid/mermaid.spec.ts
@@ -0,0 +1,182 @@
+import { Classes } from '../../src/lib/generated/classes';
+import { Specification } from '../../src/lib/generated/definitions';
+import { convertToMermaidCode, MermaidDiagram } from '../../src/lib/mermaid-converter';
+
+describe('Workflow to MermaidJS Flowchart', () => {
+  it('should build a Mermaid diagram for a workflow with a Set task, using the convertToMermaidCode function', () => {
+    const workflow = Classes.Workflow.deserialize(`
+document:
+  dsl: '1.0.0'
+  namespace: test
+  name: set
+  version: '0.1.0'
+do:
+  - initialize:
+      set:
+        foo: bar`);
+    const mermaidCode = convertToMermaidCode(workflow).trim();
+    expect(mermaidCode).toBe(
+      `flowchart TD
+    root-entry-node(( ))
+    root-exit-node((( )))
+    /do/0/initialize["initialize"]
+    /do/0/initialize --> root-exit-node
+    root-entry-node --> /do/0/initialize
+
+
+classDef hidden display: none;`.trim(),
+    );
+  });
+
+  it('should build a Mermaid diagram for a workflow with a Set task, using the instance method', () => {
+    const workflow = Classes.Workflow.deserialize(`
+document:
+  dsl: '1.0.0'
+  namespace: test
+  name: set
+  version: '0.1.0'
+do:
+  - initialize:
+      set:
+        foo: bar`);
+    const mermaidCode = workflow.toMermaidCode().trim();
+    expect(mermaidCode).toBe(
+      `flowchart TD
+    root-entry-node(( ))
+    root-exit-node((( )))
+    /do/0/initialize["initialize"]
+    /do/0/initialize --> root-exit-node
+    root-entry-node --> /do/0/initialize
+
+
+classDef hidden display: none;`.trim(),
+    );
+  });
+
+  it('should build a Mermaid diagram for a workflow with a Set task, using the static method', () => {
+    const workflow = {
+      document: {
+        dsl: '1.0.0',
+        name: 'set',
+        version: '1.0.0',
+        namespace: 'test',
+      },
+      do: [
+        {
+          initialize: {
+            set: {
+              foo: 'bar',
+            },
+          },
+        },
+      ],
+    } as Specification.Workflow;
+    const mermaidCode = Classes.Workflow.toMermaidCode(workflow).trim();
+    expect(mermaidCode).toBe(
+      `flowchart TD
+    root-entry-node(( ))
+    root-exit-node((( )))
+    /do/0/initialize["initialize"]
+    /do/0/initialize --> root-exit-node
+    root-entry-node --> /do/0/initialize
+
+
+classDef hidden display: none;`.trim(),
+    );
+  });
+
+  it('should build a Mermaid diagram for a workflow with a Set task, using the legacy MermaidDiagram class', () => {
+    const workflow = Classes.Workflow.deserialize(`
+document:
+  dsl: '1.0.0'
+  namespace: test
+  name: set
+  version: '0.1.0'
+do:
+  - initialize:
+      set:
+        foo: bar`);
+    const mermaidCode = new MermaidDiagram(workflow).sourceCode().trim();
+    expect(mermaidCode).toBe(
+      `flowchart TD
+    root-entry-node(( ))
+    root-exit-node((( )))
+    /do/0/initialize["initialize"]
+    /do/0/initialize --> root-exit-node
+    root-entry-node --> /do/0/initialize
+
+
+classDef hidden display: none;`.trim(),
+    );
+  });
+
+  it('should build a Mermaid diagram with an alternative, labelled, edge', () => {
+    const workflow = Classes.Workflow.deserialize(`
+document:
+  dsl: '1.0.0'
+  namespace: test
+  name: set
+  version: '0.1.0'
+do:
+  - initialize:
+      if: \${ input.data == true }
+      set:
+        foo: bar`);
+    const mermaidCode = convertToMermaidCode(workflow).trim();
+    expect(mermaidCode).toBe(
+      `flowchart TD
+    root-entry-node(( ))
+    root-exit-node((( )))
+    /do/0/initialize["initialize"]
+    /do/0/initialize --> root-exit-node
+    root-entry-node --"\${ input.data == true }"--> /do/0/initialize
+    root-entry-node --> root-exit-node
+
+
+classDef hidden display: none;`.trim(),
+    );
+  });
+
+  it('should build a Mermaid diagram for a workflow with a For task', () => {
+    const workflow = Classes.Workflow.deserialize(`
+document:
+  dsl: '1.0.0'
+  namespace: test
+  name: for-example
+  version: '0.1.0'
+do:
+  - checkup:
+      for:
+        each: pet
+        in: .pets
+        at: index
+      while: .vet != null
+      do:
+        - waitForCheckup:
+            listen:
+              to:
+                one:
+                  with:
+                    type: com.fake.petclinic.pets.checkup.completed.v2
+            output:
+              as: '.pets + [{ "id": $pet.id }]'`);
+    const mermaidCode = convertToMermaidCode(workflow).trim();
+    expect(mermaidCode).toBe(
+      `flowchart TD
+    root-entry-node(( ))
+    root-exit-node((( )))
+    subgraph /do/0/checkup ["checkup"]
+        /do/0/checkup-entry-node:::hidden
+        /do/0/checkup-exit-node:::hidden
+        /do/0/checkup/for/do/0/waitForCheckup["waitForCheckup"]
+        /do/0/checkup/for/do/0/waitForCheckup --- /do/0/checkup-exit-node
+        /do/0/checkup-entry-node --> /do/0/checkup/for/do/0/waitForCheckup
+    end
+    /do/0/checkup-exit-node --> root-exit-node
+    root-entry-node --- /do/0/checkup-entry-node
+
+
+classDef hidden display: none;`.trim(),
+    );
+  });
+});
diff --git a/tools/4_generate-classes.ts b/tools/4_generate-classes.ts
index c770ddc1..1bad0f77 100644
--- a/tools/4_generate-classes.ts
+++ b/tools/4_generate-classes.ts
@@ -46,7 +46,13 @@ import { Specification } from '../definitions';
 import { getLifecycleHooks } from '../../lifecycle-hooks';
 import { validate } from '../../validation';
 ${hydrationResult.code ? `import { isObject } from '../../utils';` : ''}
-${name === 'Workflow' ? `import * as yaml from 'js-yaml';` : ''}
+${
+  name === 'Workflow'
+    ? `import * as yaml from 'js-yaml';
+import { buildGraph, Graph } from '../../graph-builder';
+import { convertToMermaidCode } from '../../mermaid-converter';`
+    : ''
+}
 
 /**
  * Represents the intersection between the ${name} class and type
@@ -125,6 +131,14 @@ export class ${name} extends ${baseClass ? '_' + baseClass : `ObjectHydrator): Graph {
+    return buildGraph(model as unknown as WorkflowIntersection);
+  }
+
+  static toMermaidCode(model: Partial): string {
+    return convertToMermaidCode(model as unknown as WorkflowIntersection);
+  }
   
   /**
    * Serializes the workflow to YAML or JSON
@@ -134,6 +148,22 @@ export class ${name} extends ${baseClass ? '_' + baseClass : `ObjectHydrator, format?: 'yaml' | 'json', normalize?: boolean): string 
+  serialize(workflow: Partial, format?: 'yaml' | 'json', normalize?: boolean): string;
+
+  /**
+   * Creates a directed graph representation of the provided workflow
+   * @param workflow The workflow to convert
+   * @returns A directed graph of the provided workflow
+   */
+  toGraph(workflow: Partial): Graph;
+
+  /**
+   * Generates the MermaidJS code corresponding to the provided workflow
+   * @param workflow The workflow to convert
+   * @returns The MermaidJS code
+   */
+  toMermaidCode(workflow: Partial): string;
 }`
       : ''
   };`;