diff --git a/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/FuncDoTaskBuilder.java b/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/FuncDoTaskBuilder.java index 49dbb2b5a..176bc5690 100644 --- a/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/FuncDoTaskBuilder.java +++ b/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/FuncDoTaskBuilder.java @@ -19,6 +19,7 @@ import io.serverlessworkflow.fluent.func.spi.FuncDoFluent; import io.serverlessworkflow.fluent.func.spi.FuncTaskTransformations; import io.serverlessworkflow.fluent.spec.BaseDoTaskBuilder; +import io.serverlessworkflow.fluent.spec.WorkflowTaskBuilder; import java.util.function.Consumer; public class FuncDoTaskBuilder extends BaseDoTaskBuilder @@ -103,6 +104,24 @@ public FuncDoTaskBuilder openapi( return this; } + @Override + public FuncDoTaskBuilder workflow(String name, Consumer itemsConfigurer) { + this.listBuilder().workflow(name, itemsConfigurer); + return this; + } + + @Override + public FuncDoTaskBuilder subflow(String name, Consumer itemsConfigurer) { + this.listBuilder().subflow(name, itemsConfigurer); + return this; + } + + @Override + public FuncDoTaskBuilder subflow(Consumer itemsConfigurer) { + this.listBuilder().subflow(itemsConfigurer); + return this; + } + @Override public FuncDoTaskBuilder tryCatch(String name, Consumer itemsConfigurer) { this.listBuilder().tryCatch(name, itemsConfigurer); diff --git a/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/FuncTaskItemListBuilder.java b/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/FuncTaskItemListBuilder.java index 123fd073f..e283735be 100644 --- a/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/FuncTaskItemListBuilder.java +++ b/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/FuncTaskItemListBuilder.java @@ -22,6 +22,8 @@ import io.serverlessworkflow.api.types.TaskItem; import io.serverlessworkflow.fluent.func.spi.FuncDoFluent; import io.serverlessworkflow.fluent.spec.BaseTaskItemListBuilder; +import io.serverlessworkflow.fluent.spec.TaskItemListBuilder; +import io.serverlessworkflow.fluent.spec.WorkflowTaskBuilder; import java.util.List; import java.util.function.Consumer; @@ -165,6 +167,46 @@ public FuncTaskItemListBuilder openapi( return this.addTaskItem(new TaskItem(name, task)); } + @Override + public FuncTaskItemListBuilder workflow( + String name, Consumer itemsConfigurer) { + return this.addDelegatedWorkflow(name, itemsConfigurer, TYPE_WORKFLOW); + } + + @Override + public FuncTaskItemListBuilder workflow(Consumer itemsConfigurer) { + return this.workflow(null, itemsConfigurer); + } + + @Override + public FuncTaskItemListBuilder subflow( + String name, Consumer itemsConfigurer) { + return this.addDelegatedWorkflow(name, itemsConfigurer, TYPE_WORKFLOW); + } + + @Override + public FuncTaskItemListBuilder subflow(Consumer itemsConfigurer) { + return this.subflow(null, itemsConfigurer); + } + + private FuncTaskItemListBuilder addDelegatedWorkflow( + String name, Consumer itemsConfigurer, String delegateType) { + name = this.defaultNameAndRequireConfig(name, itemsConfigurer, delegateType); + // Preserve the original offset when delegating to avoid duplicate task names + // across appended .tasks(...) blocks + final int effectiveOffset = this.getOffset() + this.mutableList().size(); + final TaskItemListBuilder delegate = new TaskItemListBuilder(effectiveOffset); + delegate.workflow(name, itemsConfigurer); + final List taskItems = delegate.build(); + if (taskItems.size() != 1) { + throw new IllegalStateException( + String.format( + "Expected %s delegate '%s' to build exactly 1 TaskItem, but got %d", + delegateType, name, taskItems.size())); + } + return addTaskItem(taskItems.get(0)); + } + @Override public FuncTaskItemListBuilder tryCatch( String name, Consumer itemsConfigurer) { diff --git a/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/dsl/FuncDSL.java b/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/dsl/FuncDSL.java index f37c5332c..b92564758 100644 --- a/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/dsl/FuncDSL.java +++ b/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/dsl/FuncDSL.java @@ -40,9 +40,12 @@ import io.serverlessworkflow.fluent.spec.EventFilterBuilder; import io.serverlessworkflow.fluent.spec.ScheduleBuilder; import io.serverlessworkflow.fluent.spec.TimeoutBuilder; +import io.serverlessworkflow.fluent.spec.WorkflowTaskBuilder; import io.serverlessworkflow.fluent.spec.configurers.AuthenticationConfigurer; +import io.serverlessworkflow.fluent.spec.configurers.WorkflowConfigurer; import io.serverlessworkflow.fluent.spec.dsl.DSL; import io.serverlessworkflow.fluent.spec.dsl.UseSpec; +import io.serverlessworkflow.fluent.spec.dsl.WorkflowSpec; import io.serverlessworkflow.impl.TaskContextData; import io.serverlessworkflow.impl.WorkflowContextData; import java.net.URI; @@ -1116,6 +1119,107 @@ public static FuncTaskConfigurer set(Map map) { return list -> list.set(s -> s.expr(map)); } + /** + * Create a {@link FuncTaskConfigurer} that adds a sub-workflow call task using a {@link + * WorkflowConfigurer}. + * + *
{@code
+   * tasks(
+   *     subflow(
+   *         workflow("org.acme", "sub-workflow", "0.1.0")
+   *             .input("id", 99)
+   *             .await(false)
+   *     )
+   * );
+   * }
+ * + * @param configurer nested workflow configurer + * @return a {@link FuncTaskConfigurer} that adds a workflow task to the tasks list + */ + public static FuncTaskConfigurer subflow(WorkflowConfigurer configurer) { + Objects.requireNonNull(configurer, "configurer"); + return list -> list.subflow(configurer); + } + + /** + * Create a {@link FuncTaskConfigurer} that adds a named sub-workflow call task. + * + * @param name task name + * @param configurer nested workflow configurer + * @return a {@link FuncTaskConfigurer} that adds a workflow task to the tasks list + */ + public static FuncTaskConfigurer subflow(String name, Consumer configurer) { + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(configurer, "configurer"); + return list -> list.subflow(name, configurer); + } + + /** + * Create a {@link FuncTaskConfigurer} that adds an unnamed sub-workflow call task. + * + * @param configurer nested workflow configurer + * @return a {@link FuncTaskConfigurer} that adds a workflow task to the tasks list + */ + public static FuncTaskConfigurer subflow(Consumer configurer) { + Objects.requireNonNull(configurer, "configurer"); + return list -> list.subflow(configurer); + } + + /** + * Alias for {@link #subflow(WorkflowConfigurer)}. + * + * @param configurer nested workflow configurer + * @return a {@link FuncTaskConfigurer} that adds a workflow task to the tasks list + */ + public static FuncTaskConfigurer workflowTask(WorkflowConfigurer configurer) { + return subflow(configurer); + } + + /** + * Create a {@link FuncTaskConfigurer} that adds a workflow subflow task. + * + * @param configurer configurer for the nested workflow task + * @return a {@link FuncTaskConfigurer} that adds a workflow task to the tasks list + * @deprecated use {@link #subflow(WorkflowConfigurer)} to avoid ambiguity with spec-side factory + * methods + */ + @Deprecated + public static FuncTaskConfigurer workflow(WorkflowConfigurer configurer) { + return subflow(configurer); + } + + /** + * Create a new {@link WorkflowSpec} to be used as a factory for workflow definitions. + * + * @param namespace workflow namespace + * @param name workflow name + * @param version workflow version + * @return a new {@link WorkflowSpec} instance + */ + public static WorkflowSpec workflow(String namespace, String name, String version) { + return DSL.workflow(namespace, name, version); + } + + /** + * Create a new {@link WorkflowSpec} to be used as a factory for workflow definitions. + * + * @param namespace workflow namespace + * @param name workflow name + * @return a new {@link WorkflowSpec} instance + */ + public static WorkflowSpec workflow(String namespace, String name) { + return DSL.workflow(namespace, name); + } + + /** + * Create a new {@link WorkflowSpec} to be used as a factory for workflow definitions. + * + * @return a new {@link WorkflowSpec} instance + */ + public static WorkflowSpec workflow() { + return DSL.workflow(); + } + // --------------------------------------------------------------------------- // HTTP / OpenAPI // --------------------------------------------------------------------------- diff --git a/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/spi/FuncDoFluent.java b/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/spi/FuncDoFluent.java index e1aa5e203..3fd13eb5e 100644 --- a/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/spi/FuncDoFluent.java +++ b/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/spi/FuncDoFluent.java @@ -26,6 +26,7 @@ import io.serverlessworkflow.fluent.func.FuncSetTaskBuilder; import io.serverlessworkflow.fluent.func.FuncSwitchTaskBuilder; import io.serverlessworkflow.fluent.func.FuncTryTaskBuilder; +import io.serverlessworkflow.fluent.spec.WorkflowTaskBuilder; import io.serverlessworkflow.fluent.spec.spi.CallHttpFluent; import io.serverlessworkflow.fluent.spec.spi.CallOpenAPIFluent; import io.serverlessworkflow.fluent.spec.spi.EmitFluent; @@ -36,6 +37,8 @@ import io.serverlessworkflow.fluent.spec.spi.SetFluent; import io.serverlessworkflow.fluent.spec.spi.SwitchFluent; import io.serverlessworkflow.fluent.spec.spi.TryCatchFluent; +import io.serverlessworkflow.fluent.spec.spi.WorkflowFluent; +import java.util.function.Consumer; public interface FuncDoFluent> extends SetFluent, @@ -48,4 +51,14 @@ public interface FuncDoFluent> TryCatchFluent, CallFnFluent, CallHttpFluent, - CallOpenAPIFluent {} + CallOpenAPIFluent, + WorkflowFluent { + + default SELF subflow(String name, Consumer itemsConfigurer) { + return this.workflow(name, itemsConfigurer); + } + + default SELF subflow(Consumer itemsConfigurer) { + return this.workflow(itemsConfigurer); + } +} diff --git a/experimental/fluent/func/src/test/java/io/serverlessworkflow/fluent/func/FuncDSLTest.java b/experimental/fluent/func/src/test/java/io/serverlessworkflow/fluent/func/FuncDSLTest.java index 49c16e120..08c2aa003 100644 --- a/experimental/fluent/func/src/test/java/io/serverlessworkflow/fluent/func/FuncDSLTest.java +++ b/experimental/fluent/func/src/test/java/io/serverlessworkflow/fluent/func/FuncDSLTest.java @@ -23,10 +23,8 @@ import static io.serverlessworkflow.fluent.func.dsl.FuncDSL.http; import static io.serverlessworkflow.fluent.func.dsl.FuncDSL.listen; import static io.serverlessworkflow.fluent.func.dsl.FuncDSL.produced; -import static io.serverlessworkflow.fluent.func.dsl.FuncDSL.raise; import static io.serverlessworkflow.fluent.func.dsl.FuncDSL.switchWhenOrElse; import static io.serverlessworkflow.fluent.func.dsl.FuncDSL.toOne; -import static io.serverlessworkflow.fluent.func.dsl.FuncDSL.tryCatch; import static io.serverlessworkflow.fluent.spec.dsl.DSL.use; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; @@ -36,6 +34,8 @@ import io.serverlessworkflow.api.types.CallHTTP; import io.serverlessworkflow.api.types.Export; import io.serverlessworkflow.api.types.FlowDirectiveEnum; +import io.serverlessworkflow.api.types.RunTask; +import io.serverlessworkflow.api.types.RunWorkflow; import io.serverlessworkflow.api.types.Task; import io.serverlessworkflow.api.types.TaskItem; import io.serverlessworkflow.api.types.Workflow; @@ -176,133 +176,6 @@ void mixed_chaining_order_and_exports() { assertNotNull(t2.getListenTask().getExport(), "listen step should carry export"); } - @Test - void raise_and_tryCatch_build_through_func_workflow_builder() { - Workflow wf = - FuncWorkflowBuilder.workflow("raise-try") - .tasks( - FuncDSL.tasks( - raise("boom", r -> r.error(e -> e.type("org.acme.Boom").status(409))), - tryCatch( - "guarded", - t -> - t.tryHandler( - tb -> - tb.function( - cb -> - cb.function((String s) -> s.trim(), String.class))) - .catchHandler( - c -> - c.when("$.errorType == 'TEMP'") - .doTasks( - d -> - d.raise( - "handled", - r -> - r.error( - e -> - e.type("org.acme.Handled") - .status(500)))))))) - .build(); - - List items = wf.getDo(); - assertEquals(2, items.size()); - - Task raiseTask = items.get(0).getTask(); - assertNotNull(raiseTask.getRaiseTask(), "RaiseTask expected"); - var type = raiseTask.getRaiseTask().getRaise().getError().getRaiseErrorDefinition().getType(); - assertNotNull(type, "Error type expected"); - assertEquals( - "org.acme.Boom", - type.getLiteralErrorType() != null - ? type.getLiteralErrorType().getLiteralUri().toString() - : type.getExpressionErrorType()); - assertEquals( - 409, raiseTask.getRaiseTask().getRaise().getError().getRaiseErrorDefinition().getStatus()); - - Task tryTask = items.get(1).getTask(); - assertNotNull(tryTask.getTryTask(), "TryTask expected"); - assertEquals(1, tryTask.getTryTask().getTry().size(), "Try block should contain one task"); - assertNotNull( - tryTask.getTryTask().getTry().get(0).getTask().getCallTask(), - "Function task should compile inside try"); - - assertNotNull(tryTask.getTryTask().getCatch(), "Catch block expected"); - assertEquals("$.errorType == 'TEMP'", tryTask.getTryTask().getCatch().getWhen()); - assertEquals( - 1, tryTask.getTryTask().getCatch().getDo().size(), "Catch block should contain one task"); - assertNotNull( - tryTask.getTryTask().getCatch().getDo().get(0).getTask().getRaiseTask(), - "Raise task should compile inside catch"); - } - - @Test - @DisplayName("tryCatch.when(String) / then(String) / exportAs(String) no longer NPE") - void tryCatch_inherited_task_base_builder_methods_do_not_npe() { - Workflow wf = - FuncWorkflowBuilder.workflow("try-base-methods") - .tasks( - tryCatch( - "guarded", - t -> - t.tryHandler( - tb -> - tb.function( - cb -> cb.function((String s) -> s.trim(), String.class))) - .catchHandler(c -> c.when("$.errorType == 'TEMP'")) - // inherited TaskBaseBuilder methods – must not NPE - .when(".input != null") - .then("nextStep") - .exportAs("$.result"))) - .build(); - - List items = wf.getDo(); - assertEquals(1, items.size()); - - var tryTask = items.get(0).getTask().getTryTask(); - assertNotNull(tryTask, "TryTask expected"); - assertEquals(".input != null", tryTask.getIf(), "when(String) should set 'if' on TryTask"); - assertEquals( - "nextStep", tryTask.getThen().getString(), "then(String) should set FlowDirective"); - assertNotNull(tryTask.getExport(), "exportAs(String) should set Export on TryTask"); - assertEquals( - "$.result", tryTask.getExport().getAs().getString(), "exportAs value should be propagated"); - } - - @Test - @DisplayName( - "tryCatch.exportAs(Function) and when(Predicate) are available via new SPI interfaces") - void tryCatch_func_transformations_and_conditional_builder_available() { - Workflow wf = - FuncWorkflowBuilder.workflow("try-func-interfaces") - .tasks( - tryCatch( - "guarded", - t -> - t.tryHandler( - tb -> - tb.function( - cb -> cb.function((String s) -> s.trim(), String.class))) - .catchHandler(c -> c.when("$.errorType == 'TEMP'")) - // FuncTaskTransformations – function-based exportAs - .exportAs((String s) -> s.toUpperCase()) - // ConditionalTaskBuilder – predicate-based when - .when((String s) -> s != null, String.class))) - .build(); - - List items = wf.getDo(); - assertEquals(1, items.size()); - - var tryTask = items.get(0).getTask().getTryTask(); - assertNotNull(tryTask, "TryTask expected"); - - // FuncTaskTransformations.exportAs(Function) must set a non-literal Export - assertNotNull(tryTask.getExport(), "exportAs(Function) should set Export on TryTask"); - assertNull( - tryTask.getExport().getAs().getString(), - "Export 'as' must not be a literal string when using Function overload"); - } - @Test void switchWhenOrElse_jq_to_taskName() { Workflow wf = @@ -664,4 +537,79 @@ void consume_step_then_flow_directive_enum_sets_end() { callJava.getThen().getFlowDirectiveEnum(), "then() should be FlowDirectiveEnum.END"); } + + @Test + @DisplayName( + "subflow(workflow(ns,name,ver).input(...).await(...)) creates RunTask/RunWorkflow with expected values") + void subflow_workflow_spec_with_input_and_await_creates_correct_run_task() { + Workflow wf = + FuncWorkflowBuilder.workflow("parent-workflow") + .tasks( + FuncDSL.subflow( + FuncDSL.workflow("org.test", "my-subflow", "1.0.0") + .input("id", 99) + .await(false))) + .build(); + + List items = wf.getDo(); + assertEquals(1, items.size()); + + TaskItem taskItem = items.get(0); + Task t = taskItem.getTask(); + assertNotNull(t.getRunTask(), "RunTask expected for subflow"); + + RunTask runTask = t.getRunTask(); + assertNotNull(runTask.getRun(), "RunTask configuration should be present"); + + RunWorkflow runWorkflow = runTask.getRun().getRunWorkflow(); + assertNotNull(runWorkflow, "RunWorkflow should be selected"); + + // Verify workflow reference + assertEquals("org.test", runWorkflow.getWorkflow().getNamespace(), "Namespace should match"); + assertEquals("my-subflow", runWorkflow.getWorkflow().getName(), "Name should match"); + assertEquals("1.0.0", runWorkflow.getWorkflow().getVersion(), "Version should match"); + + // Verify input (accessed through workflow config) + assertNotNull(runWorkflow.getWorkflow().getInput(), "Input should be set"); + assertEquals( + 99, + runWorkflow.getWorkflow().getInput().getAdditionalProperties().get("id"), + "Input 'id' should match"); + + // Verify await + assertEquals(false, runWorkflow.isAwait(), "Await should be false"); + } + + @Test + @DisplayName("subflow(name, workflow(...)) creates named subflow task with RunTask/RunWorkflow") + void subflow_named_with_workflow_spec_creates_named_run_task() { + Workflow wf = + FuncWorkflowBuilder.workflow("parent-workflow") + .tasks( + FuncDSL.subflow( + "mySubflowTask", + w -> + w.namespace("org.acme") + .name("child-workflow") + .version("2.0.0") + .input("key", "value") + .await(true))) + .build(); + + List items = wf.getDo(); + assertEquals(1, items.size()); + + TaskItem taskItem = items.get(0); + assertEquals("mySubflowTask", taskItem.getName(), "Task name should match"); + + Task t = taskItem.getTask(); + assertNotNull(t.getRunTask(), "RunTask expected for subflow"); + + RunWorkflow runWorkflow = t.getRunTask().getRun().getRunWorkflow(); + assertNotNull(runWorkflow, "RunWorkflow should be selected"); + assertEquals("org.acme", runWorkflow.getWorkflow().getNamespace()); + assertEquals("child-workflow", runWorkflow.getWorkflow().getName()); + assertEquals("2.0.0", runWorkflow.getWorkflow().getVersion()); + assertEquals(true, runWorkflow.isAwait(), "Await should be true"); + } } diff --git a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/BaseTaskItemListBuilder.java b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/BaseTaskItemListBuilder.java index e42b23b6b..977543cd0 100644 --- a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/BaseTaskItemListBuilder.java +++ b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/BaseTaskItemListBuilder.java @@ -79,6 +79,15 @@ protected final List mutableList() { return this.list; } + /** + * Returns the offset value used for auto-generating task names. + * + * @return the offset that was set during construction + */ + protected final int getOffset() { + return this.offset; + } + protected final SELF addTaskItem(TaskItem taskItem) { Objects.requireNonNull(taskItem, "taskItem must not be null"); list.add(taskItem);