diff --git a/impl/test/pom.xml b/impl/test/pom.xml index 33a81db5..e9e24013 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 d2ed4407..9be63737 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 84e95e1e..35b1985f 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 cc908b1f..a8bf17bb 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 b218fb25..157b894e 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 0ee27c58..d47dd4c0 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 00000000..e0f671ae --- /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 new file mode 100644 index 00000000..6f374652 --- /dev/null +++ b/mermaid/pom.xml @@ -0,0 +1,67 @@ + + + 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.junit.jupiter + junit-jupiter-params + test + + + org.junit.jupiter + junit-jupiter-engine + test + + + org.mockito + mockito-core + test + + + org.assertj + 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 00000000..c9a6bdbd --- /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 new file mode 100644 index 00000000..3bd71e79 --- /dev/null +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/DefaultNodeRenderer.java @@ -0,0 +1,61 @@ +/* + * 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; + + public DefaultNodeRenderer(Node node) { + this.node = node; + } + + protected final Node getNode() { + return node; + } + + public void render(StringBuilder sb, int level) { + sb.append(ind(level)) + .append(node.id) + .append("@{ shape: ") + .append(node.type.mermaidShape()) + .append(", label: \"") + .append(NodeRenderer.escLabel(node.label)) + .append("\" }\n"); + this.renderBody(sb, level); + this.renderEdge(sb, level); + } + + protected void renderBody(StringBuilder sb, int level) { + if (!this.node.branches.isEmpty()) { + MermaidRenderer.render(this.getNode().getBranches(), sb, level + 1); + } + } + + protected void renderEdge(StringBuilder sb, int level) { + for (Edge edge : this.getNode().getEdge()) { + sb.append(ind(level)) + .append(node.getId()) + .append(edge.getArrow()) + .append(edge.getNodeId()) + .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/Edge.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/Edge.java new file mode 100644 index 00000000..7d86d410 --- /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/EmitNode.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/EmitNode.java new file mode 100644 index 00000000..d355a467 --- /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/EndpointStringify.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/EndpointStringify.java new file mode 100644 index 00000000..dfd6bb48 --- /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 new file mode 100644 index 00000000..5a1f5674 --- /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.addBranches(new MermaidGraph().build(forTask.getDo())); + final Node firstTask = this.branches.get(forTask.getDo().get(0).getName()); + + note.addEdge(Edge.to(loop)); + loop.addEdge(Edge.to(firstTask)); + + String lastForTask = forTask.getDo().get(forTask.getDo().size() - 1).getName(); + String renderedArrow = "-. |edge| .->"; + if (forTask.getWhile() != null && !forTask.getWhile().isEmpty()) { + renderedArrow = "-. |while: " + NodeRenderer.escLabel(forTask.getWhile()) + "| .->"; + } + + 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 new file mode 100644 index 00000000..d3d5dc27 --- /dev/null +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/ForkNode.java @@ -0,0 +1,69 @@ +/* + * 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 + Node split = NodeBuilder.split(); + String competeLabel = fork.getFork().isCompete() ? "ANY" : "ALL"; + Node join = new Node(Ids.random(), 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.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 new file mode 100644 index 00000000..424d6588 --- /dev/null +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/Ids.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.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; + +public final class Ids { + private final String salt = Integer.toString(ThreadLocalRandom.current().nextInt(), 36); + private final AtomicInteger seq = new AtomicInteger(); + + public static String random() { + 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 new file mode 100644 index 00000000..fc40dcb2 --- /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.random(), 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.addBranches(new MermaidGraph().build(iterator.getDo())); + final Node firstTask = this.branches.get(iterator.getDo().get(0).getName()); + + note.addEdge(Edge.to(loop)); + loop.addEdge(Edge.to(firstTask)); + + String lastForTask = iterator.getDo().get(iterator.getDo().size() - 1).getName(); + String renderedArrow = "-. |edge| .->"; + + 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 new file mode 100644 index 00000000..b4be12ca --- /dev/null +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/ListenNode.java @@ -0,0 +1,92 @@ +/* + * 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.escLabel( + 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.withEdge(Edge.to(inner)); + nodeNote.withEdge(Edge.to(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); + Edge forEachEdge = Edge.to(forEach); + inner.addEdge(forEachEdge); + forEach.addEdge(Edge.to(junctionNote)); + + if (!junctionArrow.isEmpty()) { + forEachEdge.setArrow(junctionArrow); + } + } else if (!junctionArrow.isEmpty()) { + 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 new file mode 100644 index 00000000..befc2bca --- /dev/null +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/Mermaid.java @@ -0,0 +1,55 @@ +/* + * 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; + +/** Main entrypoint to generate a Mermaid representation of a Workflow definition. */ +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 00000000..39385bc4 --- /dev/null +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/MermaidGraph.java @@ -0,0 +1,113 @@ +/* + * 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 { + + static final String START_NODE_ID = "n__start__"; + static final String END_NODE_ID = "n__end__"; + + 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 = 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.getEdge().isEmpty() && n.getType() != NodeType.START && n.getType() != NodeType.STOP) { + n.addEdge(Edge.to(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)); + + for (int i = 0; i < tasks.size(); i++) { + TaskItem task = tasks.get(i); + 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); + 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; + } + } + } + + 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.addEdge(Edge.to(to)); + } + } + + return graph; + } +} 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 00000000..64a45c74 --- /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/MermaidRenderer.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/MermaidRenderer.java new file mode 100644 index 00000000..07f6c2f6 --- /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 00000000..00e0916f --- /dev/null +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/Node.java @@ -0,0 +1,136 @@ +/* + * 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.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 NodeType type; + protected NodeRenderer renderer; + private String defaultEdgeArrow; + + 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); + this.edge = new HashSet<>(); + } + + public Node(String id, String label, NodeType type) { + this(id, label); + this.type = type; + } + + public Node withEdge(Edge edge) { + this.edge.add(edge); + return this; + } + + public NodeType getType() { + return type; + } + + public Set getEdge() { + return Collections.unmodifiableSet(edge); + } + + public void addEdge(Edge edge) { + if (edge == null) { + return; + } + if (defaultEdgeArrow != null) { + edge.setArrow(defaultEdgeArrow); + } + this.edge.add(edge); + } + + public String getId() { + return id; + } + + public String getLabel() { + return NodeRenderer.escLabel(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 Node withDefaultEdgeArrow(String edgeArrow) { + this.defaultEdgeArrow = edgeArrow; + return this; + } + + /** 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 + + ", edge=" + + edge + + ", 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(edge, node.edge) + && type == node.type; + } + + @Override + public int hashCode() { + 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 new file mode 100644 index 00000000..7b52a0ce --- /dev/null +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/NodeBuilder.java @@ -0,0 +1,99 @@ +/* + * 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; + +public final class NodeBuilder { + + private NodeBuilder() {} + + public static Node note(String label) { + 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.random(), "join", NodeType.JUNCTION); + } + + public static Node split() { + return new Node(Ids.random(), "split", NodeType.SPLIT); + } + + public static Node rect(String label) { + return new Node(Ids.random(), label, NodeType.RECT); + } + + public static Node tryBlock() { + return new SubgraphNode(Ids.random(), "Try", NodeType.TRY_BLOCK) + .withDefaultEdgeArrow("-. |onError| .->"); + } + + public static Node subgraph(String label) { + return new SubgraphNode(Ids.random(), label); + } + + 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) { + 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); + } 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); + } + + 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 new file mode 100644 index 00000000..67d52a24 --- /dev/null +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/NodeRenderer.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; + +public interface NodeRenderer { + + void render(StringBuilder sb, int level); + + static String escLabel(String s) { + if (s == null) return ""; + return s.replace("\\", "\\\\") + .replace("\"", "#quot;") + .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 00000000..8a243cdb --- /dev/null +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/NodeType.java @@ -0,0 +1,45 @@ +/* + * 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"), + SWITCH("diam"), + TRY_CATCH("subgraph"), + TRY_BLOCK("subgraph"), + NOTE("note"), + SPLIT("sm-circ"), + 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; + + NodeType(String type) { + this.type = type; + } + + public String mermaidShape() { + return this.type; + } +} diff --git a/mermaid/src/main/java/io/serverlessworkflow/mermaid/RaiseNode.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/RaiseNode.java new file mode 100644 index 00000000..bc32f4b1 --- /dev/null +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/RaiseNode.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 io.serverlessworkflow.api.types.TaskItem; + +public class RaiseNode extends TaskNode { + + 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 00000000..31d26f53 --- /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/SubgraphNode.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/SubgraphNode.java new file mode 100644 index 00000000..b38c3c39 --- /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 00000000..2054a4f9 --- /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.escLabel(getNode().getLabel())) + .append("\"]\n"); + this.renderBody(sb, level); + this.renderEdge(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/SwitchNode.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/SwitchNode.java new file mode 100644 index 00000000..0d398925 --- /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 new file mode 100644 index 00000000..0afc21dd --- /dev/null +++ b/mermaid/src/main/java/io/serverlessworkflow/mermaid/TaskNode.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; + +import io.serverlessworkflow.api.types.TaskItem; + +public class TaskNode extends Node { + + protected final TaskItem task; + + public TaskNode(String label, TaskItem task, NodeType type) { + 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/TaskSubgraphNode.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/TaskSubgraphNode.java new file mode 100644 index 00000000..1f9239c8 --- /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/TryCatchNode.java b/mermaid/src/main/java/io/serverlessworkflow/mermaid/TryCatchNode.java new file mode 100644 index 00000000..9f7155dd --- /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.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 00000000..08301ed5 --- /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 00000000..d1991d89 --- /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 00000000..e73b1d1b --- /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/pom.xml b/pom.xml index 5cf1deb7..ce4c5275 100644 --- a/pom.xml +++ b/pom.xml @@ -46,6 +46,7 @@ examples experimental fluent + mermaid