From 3e36fa68e9e9cc53506f5969239cfd7056d4dc23 Mon Sep 17 00:00:00 2001 From: Dmitrii Tikhomirov Date: Tue, 4 Nov 2025 02:59:07 -0800 Subject: [PATCH 01/13] a fix for PR #953 (#961) Signed-off-by: Dmitrii Tikhomirov --- .../fluent/agentic/AgenticWorkflowHelperIT.java | 7 ++++--- .../java/io/serverlessworkflow/fluent/agentic/Agents.java | 7 +++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/experimental/fluent/agentic/src/test/java/io/serverlessworkflow/fluent/agentic/AgenticWorkflowHelperIT.java b/experimental/fluent/agentic/src/test/java/io/serverlessworkflow/fluent/agentic/AgenticWorkflowHelperIT.java index a6ca6a5c..69a5cf77 100644 --- a/experimental/fluent/agentic/src/test/java/io/serverlessworkflow/fluent/agentic/AgenticWorkflowHelperIT.java +++ b/experimental/fluent/agentic/src/test/java/io/serverlessworkflow/fluent/agentic/AgenticWorkflowHelperIT.java @@ -178,10 +178,11 @@ public void errorHandling() { Map input = Map.of( - "style", "fantasy", + "topic", "fantasy", + "style", "funny", "audience", "young adults"); - Map result; + Map result = null; try (WorkflowApplication app = WorkflowApplication.builder().build()) { result = app.workflowDefinition(wf).instance(input).start().get().asMap().orElseThrow(); } catch (Exception e) { @@ -210,7 +211,7 @@ public void conditionalWorkflow() { conditional(RequestCategory.LEGAL::equals, legalExpert))) .build(); - Map input = Map.of("question", "What is the best treatment for a common cold?"); + Map input = Map.of("request", "What is the best treatment for a common cold?"); Map result; try (WorkflowApplication app = WorkflowApplication.builder().build()) { diff --git a/experimental/fluent/agentic/src/test/java/io/serverlessworkflow/fluent/agentic/Agents.java b/experimental/fluent/agentic/src/test/java/io/serverlessworkflow/fluent/agentic/Agents.java index f91dae00..80323ee7 100644 --- a/experimental/fluent/agentic/src/test/java/io/serverlessworkflow/fluent/agentic/Agents.java +++ b/experimental/fluent/agentic/src/test/java/io/serverlessworkflow/fluent/agentic/Agents.java @@ -15,7 +15,6 @@ */ package io.serverlessworkflow.fluent.agentic; -import dev.langchain4j.agent.tool.Tool; import dev.langchain4j.agentic.Agent; import dev.langchain4j.agentic.internal.AgentSpecification; import dev.langchain4j.agentic.scope.AgenticScopeAccess; @@ -371,7 +370,7 @@ interface MedicalExpert { Analyze the following user request under a medical point of view and provide the best possible answer. The user request is {{it}}. """) - @Tool("A medical expert") + @Agent("A medical expert") String medicalRequest(String request); } @@ -383,7 +382,7 @@ interface LegalExpert { Analyze the following user request under a legal point of view and provide the best possible answer. The user request is {{it}}. """) - @Tool("A legal expert") + @Agent("A legal expert") String legalRequest(String request); } @@ -395,7 +394,7 @@ interface TechnicalExpert { Analyze the following user request under a technical point of view and provide the best possible answer. The user request is {{it}}. """) - @Tool("A technical expert") + @Agent("A technical expert") String technicalRequest(String request); } From 239d4bae63bfe98b36ee73fb03313fe93acbe9fe Mon Sep 17 00:00:00 2001 From: Ricardo Zanini <1538000+ricardozanini@users.noreply.github.com> Date: Tue, 4 Nov 2025 08:48:56 -0500 Subject: [PATCH 02/13] chore: do not wait for artifacts to be published (#960) Signed-off-by: Ricardo Zanini --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index bfdfbd5b..83bc804b 100644 --- a/pom.xml +++ b/pom.xml @@ -275,7 +275,7 @@ central true - published + uploaded From 9338a0598cc286a31c104ef43825f5e753f74ef2 Mon Sep 17 00:00:00 2001 From: Francisco Javier Tirado Sarti <65240126+fjtirado@users.noreply.github.com> Date: Wed, 5 Nov 2025 10:05:12 +0100 Subject: [PATCH 03/13] [Fix #933] Adding timeout support (#963) Signed-off-by: fjtirado --- .../impl/WorkflowError.java | 4 ++ .../impl/executors/AbstractTaskExecutor.java | 58 ++++++++++++++++++- .../{RetryTest.java => RetryTimeoutTest.java} | 17 +++++- .../listen-to-one-timeout.yaml | 26 +++++++++ 4 files changed, 101 insertions(+), 4 deletions(-) rename impl/test/src/test/java/io/serverlessworkflow/impl/test/{RetryTest.java => RetryTimeoutTest.java} (87%) create mode 100644 impl/test/src/test/resources/workflows-samples/listen-to-one-timeout.yaml diff --git a/impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowError.java b/impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowError.java index a23c51dd..bb44f3e6 100644 --- a/impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowError.java +++ b/impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowError.java @@ -56,6 +56,10 @@ public static Builder runtime(TaskContext context, Exception ex) { return runtime(Errors.RUNTIME.status(), context, ex); } + public static Builder timeout() { + return error(Errors.TIMEOUT.toString(), Errors.TIMEOUT.status()); + } + public static class Builder { private final String type; diff --git a/impl/core/src/main/java/io/serverlessworkflow/impl/executors/AbstractTaskExecutor.java b/impl/core/src/main/java/io/serverlessworkflow/impl/executors/AbstractTaskExecutor.java index 30956970..055cbba6 100644 --- a/impl/core/src/main/java/io/serverlessworkflow/impl/executors/AbstractTaskExecutor.java +++ b/impl/core/src/main/java/io/serverlessworkflow/impl/executors/AbstractTaskExecutor.java @@ -24,17 +24,23 @@ import io.serverlessworkflow.api.types.Input; import io.serverlessworkflow.api.types.Output; import io.serverlessworkflow.api.types.TaskBase; +import io.serverlessworkflow.api.types.TaskTimeout; +import io.serverlessworkflow.api.types.Timeout; import io.serverlessworkflow.api.types.Workflow; import io.serverlessworkflow.impl.TaskContext; import io.serverlessworkflow.impl.WorkflowApplication; import io.serverlessworkflow.impl.WorkflowContext; import io.serverlessworkflow.impl.WorkflowDefinition; +import io.serverlessworkflow.impl.WorkflowError; +import io.serverlessworkflow.impl.WorkflowException; import io.serverlessworkflow.impl.WorkflowFilter; import io.serverlessworkflow.impl.WorkflowModel; import io.serverlessworkflow.impl.WorkflowMutablePosition; import io.serverlessworkflow.impl.WorkflowPosition; import io.serverlessworkflow.impl.WorkflowPredicate; import io.serverlessworkflow.impl.WorkflowStatus; +import io.serverlessworkflow.impl.WorkflowUtils; +import io.serverlessworkflow.impl.WorkflowValueResolver; import io.serverlessworkflow.impl.lifecycle.TaskCancelledEvent; import io.serverlessworkflow.impl.lifecycle.TaskCompletedEvent; import io.serverlessworkflow.impl.lifecycle.TaskFailedEvent; @@ -42,13 +48,16 @@ import io.serverlessworkflow.impl.lifecycle.TaskStartedEvent; import io.serverlessworkflow.impl.resources.ResourceLoader; import io.serverlessworkflow.impl.schema.SchemaValidator; +import java.time.Duration; import java.time.Instant; import java.util.Iterator; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; +import java.util.concurrent.TimeUnit; public abstract class AbstractTaskExecutor implements TaskExecutor { @@ -62,6 +71,7 @@ public abstract class AbstractTaskExecutor implements TaskEx private final Optional outputSchemaValidator; private final Optional contextSchemaValidator; private final Optional ifFilter; + private final Optional> timeout; public abstract static class AbstractTaskExecutorBuilder< T extends TaskBase, V extends AbstractTaskExecutor> @@ -80,6 +90,7 @@ public abstract static class AbstractTaskExecutorBuilder< protected final Workflow workflow; protected final ResourceLoader resourceLoader; private final WorkflowDefinition definition; + private final Optional> timeout; private V instance; @@ -113,6 +124,28 @@ protected AbstractTaskExecutorBuilder( getSchemaValidator(application.validatorFactory(), resourceLoader, export.getSchema()); } this.ifFilter = application.expressionFactory().buildIfFilter(task); + this.timeout = getTaskTimeout(); + } + + private Optional> getTaskTimeout() { + TaskTimeout timeout = task.getTimeout(); + if (timeout == null) { + return Optional.empty(); + } + Timeout timeoutDef = timeout.getTaskTimeoutDefinition(); + if (timeoutDef == null && timeout.getTaskTimeoutReference() != null) { + timeoutDef = + Objects.requireNonNull( + Objects.requireNonNull( + workflow.getUse().getTimeouts(), + "Timeout reference " + + timeout.getTaskTimeoutReference() + + " specified, but use timeout was not defined") + .getAdditionalProperties() + .get(timeout.getTaskTimeoutReference()), + "Timeout reference " + timeout.getTaskTimeoutReference() + "cannot be found"); + } + return Optional.of(WorkflowUtils.fromTimeoutAfter(application, timeoutDef.getAfter())); } protected final TransitionInfoBuilder next( @@ -171,6 +204,7 @@ protected AbstractTaskExecutor(AbstractTaskExecutorBuilder builder) { this.outputSchemaValidator = builder.outputSchemaValidator; this.contextSchemaValidator = builder.contextSchemaValidator; this.ifFilter = builder.ifFilter; + this.timeout = builder.timeout; } protected final CompletableFuture executeNext( @@ -200,7 +234,7 @@ public CompletableFuture apply( } else if (taskContext.isCompleted()) { return executeNext(completable, workflowContext); } else if (ifFilter.map(f -> f.test(workflowContext, taskContext, input)).orElse(true)) { - return executeNext( + completable = completable .thenCompose(workflowContext.instance()::suspendedCheck) .thenApply( @@ -247,8 +281,26 @@ public CompletableFuture apply( l.onTaskCompleted( new TaskCompletedEvent(workflowContext, taskContext))); return t; - }), - workflowContext); + }); + if (timeout.isPresent()) { + completable = + completable + .orTimeout( + timeout + .map(t -> t.apply(workflowContext, taskContext, input)) + .orElseThrow() + .toMillis(), + TimeUnit.MILLISECONDS) + .exceptionallyCompose( + e -> + CompletableFuture.failedFuture( + new WorkflowException( + WorkflowError.timeout() + .instance(taskContext.position().jsonPointer()) + .build(), + e))); + } + return executeNext(completable, workflowContext); } else { taskContext.transition(getSkipTransition()); return executeNext(completable, workflowContext); diff --git a/impl/test/src/test/java/io/serverlessworkflow/impl/test/RetryTest.java b/impl/test/src/test/java/io/serverlessworkflow/impl/test/RetryTimeoutTest.java similarity index 87% rename from impl/test/src/test/java/io/serverlessworkflow/impl/test/RetryTest.java rename to impl/test/src/test/java/io/serverlessworkflow/impl/test/RetryTimeoutTest.java index 76846272..586cc018 100644 --- a/impl/test/src/test/java/io/serverlessworkflow/impl/test/RetryTest.java +++ b/impl/test/src/test/java/io/serverlessworkflow/impl/test/RetryTimeoutTest.java @@ -16,7 +16,9 @@ package io.serverlessworkflow.impl.test; import static io.serverlessworkflow.api.WorkflowReader.readWorkflowFromClasspath; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.Assert.assertThat; import com.fasterxml.jackson.databind.JsonNode; import io.serverlessworkflow.impl.WorkflowApplication; @@ -38,7 +40,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; -public class RetryTest { +public class RetryTimeoutTest { private static WorkflowApplication app; private MockWebServer apiServer; @@ -106,4 +108,17 @@ void testRetryEnd() throws IOException { .join()) .hasCauseInstanceOf(WorkflowException.class); } + + @Test + void testTimeout() throws IOException { + Map result = + app.workflowDefinition( + readWorkflowFromClasspath("workflows-samples/listen-to-one-timeout.yaml")) + .instance(Map.of("delay", 0.01f)) + .start() + .join() + .asMap() + .orElseThrow(); + assertThat(result.get("message")).isEqualTo("Viva er Beti Balompie"); + } } diff --git a/impl/test/src/test/resources/workflows-samples/listen-to-one-timeout.yaml b/impl/test/src/test/resources/workflows-samples/listen-to-one-timeout.yaml new file mode 100644 index 00000000..2f3845a9 --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/listen-to-one-timeout.yaml @@ -0,0 +1,26 @@ +document: + dsl: '1.0.0-alpha5' + namespace: test + name: listen-to-one-timeout + version: '0.1.0' +do: + - tryListen: + try: + - waitingNotForever: + listen: + to: + one: + with: + type: neven-happening-event + timeout: + after: ${"PT\(.delay)S"} + catch: + errors: + with: + type: https://serverlessworkflow.io/spec/1.0.0/errors/timeout + status: 408 + do: + - setMessage: + set: + message: Viva er Beti Balompie + \ No newline at end of file From 9b0b0a2cd4fcc6064f3daff065295851b8a413a7 Mon Sep 17 00:00:00 2001 From: Francisco Javier Tirado Sarti <65240126+fjtirado@users.noreply.github.com> Date: Fri, 7 Nov 2025 17:35:30 +0100 Subject: [PATCH 04/13] [Fix #932] Workflow scheduler (#966) Signed-off-by: fjtirado --- impl/core/pom.xml | 4 + .../impl/SchedulerListener.java | 69 +++++++++++ .../impl/WorkflowApplication.java | 12 +- .../impl/WorkflowDefinition.java | 60 ++++++++-- .../impl/scheduler/Cancellable.java | 20 ++++ .../impl/scheduler/CronResolver.java | 23 ++++ .../impl/scheduler/CronResolverFactory.java | 20 ++++ .../impl/scheduler/CronUtilsResolver.java | 36 ++++++ .../scheduler/CronUtilsResolverFactory.java | 38 ++++++ .../scheduler/DefaultWorkflowScheduler.java | 113 ++++++++++++++++-- .../scheduler/ScheduledEventConsumer.java | 20 ++-- .../scheduler/ScheduledInstanceRunnable.java | 44 +++++++ .../ScheduledServiceCancellable.java | 32 +++++ .../{ => scheduler}/WorkflowScheduler.java | 22 +++- impl/pom.xml | 6 + .../impl/test/SchedulerTest.java | 84 +++++++++++++ .../workflows-samples/after-start.yaml | 12 ++ .../workflows-samples/cron-start.yaml | 11 ++ .../workflows-samples/every-start.yaml | 12 ++ 19 files changed, 604 insertions(+), 34 deletions(-) create mode 100644 impl/core/src/main/java/io/serverlessworkflow/impl/SchedulerListener.java create mode 100644 impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/Cancellable.java create mode 100644 impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/CronResolver.java create mode 100644 impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/CronResolverFactory.java create mode 100644 impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/CronUtilsResolver.java create mode 100644 impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/CronUtilsResolverFactory.java create mode 100644 impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/ScheduledInstanceRunnable.java create mode 100644 impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/ScheduledServiceCancellable.java rename impl/core/src/main/java/io/serverlessworkflow/impl/{ => scheduler}/WorkflowScheduler.java (54%) create mode 100644 impl/test/src/test/java/io/serverlessworkflow/impl/test/SchedulerTest.java create mode 100644 impl/test/src/test/resources/workflows-samples/after-start.yaml create mode 100644 impl/test/src/test/resources/workflows-samples/cron-start.yaml create mode 100644 impl/test/src/test/resources/workflows-samples/every-start.yaml diff --git a/impl/core/pom.xml b/impl/core/pom.xml index 0ee45fe9..6fb99f6d 100644 --- a/impl/core/pom.xml +++ b/impl/core/pom.xml @@ -24,5 +24,9 @@ de.huxhorn.sulky de.huxhorn.sulky.ulid + + com.cronutils + cron-utils + diff --git a/impl/core/src/main/java/io/serverlessworkflow/impl/SchedulerListener.java b/impl/core/src/main/java/io/serverlessworkflow/impl/SchedulerListener.java new file mode 100644 index 00000000..2aa57694 --- /dev/null +++ b/impl/core/src/main/java/io/serverlessworkflow/impl/SchedulerListener.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.impl; + +import io.serverlessworkflow.impl.lifecycle.WorkflowCompletedEvent; +import io.serverlessworkflow.impl.lifecycle.WorkflowExecutionListener; +import io.serverlessworkflow.impl.scheduler.Cancellable; +import io.serverlessworkflow.impl.scheduler.WorkflowScheduler; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +class SchedulerListener implements WorkflowExecutionListener, AutoCloseable { + + private final WorkflowScheduler scheduler; + private final Map> afterMap = + new ConcurrentHashMap<>(); + private final Map cancellableMap = new ConcurrentHashMap<>(); + + public SchedulerListener(WorkflowScheduler scheduler) { + this.scheduler = scheduler; + } + + public void addAfter(WorkflowDefinition definition, WorkflowValueResolver after) { + afterMap.put(definition, after); + } + + @Override + public void onWorkflowCompleted(WorkflowCompletedEvent ev) { + WorkflowDefinition workflowDefinition = (WorkflowDefinition) ev.workflowContext().definition(); + WorkflowValueResolver after = afterMap.get(workflowDefinition); + if (after != null) { + cancellableMap.put( + workflowDefinition, + scheduler.scheduleAfter( + workflowDefinition, + after.apply((WorkflowContext) ev.workflowContext(), null, ev.output()))); + } + } + + public void removeAfter(WorkflowDefinition definition) { + if (afterMap.remove(definition) != null) { + Cancellable cancellable = cancellableMap.remove(definition); + if (cancellable != null) { + cancellable.cancel(); + } + } + } + + @Override + public void close() { + cancellableMap.values().forEach(c -> c.cancel()); + cancellableMap.clear(); + afterMap.clear(); + } +} diff --git a/impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowApplication.java b/impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowApplication.java index 9865f72b..413bfbe8 100644 --- a/impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowApplication.java +++ b/impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowApplication.java @@ -36,6 +36,7 @@ import io.serverlessworkflow.impl.resources.ExternalResourceHandler; import io.serverlessworkflow.impl.resources.ResourceLoaderFactory; import io.serverlessworkflow.impl.scheduler.DefaultWorkflowScheduler; +import io.serverlessworkflow.impl.scheduler.WorkflowScheduler; import io.serverlessworkflow.impl.schema.SchemaValidator; import io.serverlessworkflow.impl.schema.SchemaValidatorFactory; import java.util.ArrayList; @@ -71,6 +72,7 @@ public class WorkflowApplication implements AutoCloseable { private final Map> additionalObjects; private final ConfigManager configManager; private final SecretManager secretManager; + private final SchedulerListener schedulerListener; private WorkflowApplication(Builder builder) { this.taskFactory = builder.taskFactory; @@ -81,13 +83,14 @@ private WorkflowApplication(Builder builder) { this.idFactory = builder.idFactory; this.runtimeDescriptorFactory = builder.descriptorFactory; this.executorFactory = builder.executorFactory; - this.listeners = builder.listeners != null ? builder.listeners : Collections.emptySet(); + this.listeners = builder.listeners; this.definitions = new ConcurrentHashMap<>(); this.eventConsumer = builder.eventConsumer; this.eventPublishers = builder.eventPublishers; this.lifeCycleCEPublishingEnabled = builder.lifeCycleCEPublishingEnabled; this.modelFactory = builder.modelFactory; this.scheduler = builder.scheduler; + this.schedulerListener = builder.schedulerListener; this.additionalObjects = builder.additionalObjects; this.configManager = builder.configManager; this.secretManager = builder.secretManager; @@ -169,6 +172,7 @@ public SchemaValidator getValidator(SchemaInline inline) { private Map> additionalObjects; private SecretManager secretManager; private ConfigManager configManager; + private SchedulerListener schedulerListener; private Builder() {} @@ -304,6 +308,8 @@ public WorkflowApplication build() { if (scheduler == null) { scheduler = new DefaultWorkflowScheduler(); } + schedulerListener = new SchedulerListener(scheduler); + listeners.add(schedulerListener); if (additionalObjects == null) { additionalObjects = Collections.emptyMap(); } @@ -388,6 +394,10 @@ public SecretManager secretManager() { return secretManager; } + SchedulerListener schedulerListener() { + return schedulerListener; + } + public Optional additionalObject( String name, WorkflowContext workflowContext, TaskContext taskContext) { return Optional.ofNullable(additionalObjects.get(name)) diff --git a/impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowDefinition.java b/impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowDefinition.java index 41c5c2ee..6fe8856a 100644 --- a/impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowDefinition.java +++ b/impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowDefinition.java @@ -20,7 +20,6 @@ import static io.serverlessworkflow.impl.WorkflowUtils.safeClose; import io.serverlessworkflow.api.types.Input; -import io.serverlessworkflow.api.types.ListenTo; import io.serverlessworkflow.api.types.Output; import io.serverlessworkflow.api.types.Schedule; import io.serverlessworkflow.api.types.Workflow; @@ -28,16 +27,20 @@ import io.serverlessworkflow.impl.executors.TaskExecutor; import io.serverlessworkflow.impl.executors.TaskExecutorHelper; import io.serverlessworkflow.impl.resources.ResourceLoader; +import io.serverlessworkflow.impl.scheduler.Cancellable; import io.serverlessworkflow.impl.scheduler.ScheduledEventConsumer; +import io.serverlessworkflow.impl.scheduler.WorkflowScheduler; import io.serverlessworkflow.impl.schema.SchemaValidator; import java.nio.file.Path; import java.util.HashMap; import java.util.Map; +import java.util.Objects; import java.util.Optional; public class WorkflowDefinition implements AutoCloseable, WorkflowDefinitionData { private final Workflow workflow; + private final WorkflowDefinitionId definitionId; private Optional inputSchemaValidator = Optional.empty(); private Optional outputSchemaValidator = Optional.empty(); private Optional inputFilter = Optional.empty(); @@ -47,10 +50,13 @@ public class WorkflowDefinition implements AutoCloseable, WorkflowDefinitionData private final ResourceLoader resourceLoader; private final Map> executors = new HashMap<>(); private ScheduledEventConsumer scheculedConsumer; + private Cancellable everySchedule; + private Cancellable cronSchedule; private WorkflowDefinition( WorkflowApplication application, Workflow workflow, ResourceLoader resourceLoader) { this.workflow = workflow; + this.definitionId = WorkflowDefinitionId.of(workflow); this.application = application; this.resourceLoader = resourceLoader; @@ -84,15 +90,28 @@ static WorkflowDefinition of(WorkflowApplication application, Workflow workflow, application.resourceLoaderFactory().getResourceLoader(application, path)); Schedule schedule = workflow.getSchedule(); if (schedule != null) { - ListenTo to = schedule.getOn(); - if (to != null) { + WorkflowScheduler scheduler = application.scheduler(); + if (schedule.getOn() != null) { definition.scheculedConsumer = - application - .scheduler() - .eventConsumer( - definition, - application.modelFactory()::from, - EventRegistrationBuilderInfo.from(application, to, x -> null)); + scheduler.eventConsumer( + definition, + application.modelFactory()::from, + EventRegistrationBuilderInfo.from(application, schedule.getOn(), x -> null)); + } + if (schedule.getAfter() != null) { + application + .schedulerListener() + .addAfter(definition, WorkflowUtils.fromTimeoutAfter(application, schedule.getAfter())); + } + if (schedule.getCron() != null) { + definition.cronSchedule = scheduler.scheduleCron(definition, schedule.getCron()); + } + if (schedule.getEvery() != null) { + definition.everySchedule = + scheduler.scheduleEvery( + definition, + WorkflowUtils.fromTimeoutAfter(application, schedule.getEvery()) + .apply(null, null, application.modelFactory().fromNull())); } } return definition; @@ -148,7 +167,28 @@ public void addTaskExecutor(WorkflowMutablePosition position, TaskExecutor ta @Override public void close() { - safeClose(scheculedConsumer); safeClose(resourceLoader); + safeClose(scheculedConsumer); + application.schedulerListener().removeAfter(this); + if (everySchedule != null) { + everySchedule.cancel(); + } + if (cronSchedule != null) { + cronSchedule.cancel(); + } + } + + @Override + public int hashCode() { + return Objects.hash(definitionId); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null) return false; + if (getClass() != obj.getClass()) return false; + WorkflowDefinition other = (WorkflowDefinition) obj; + return Objects.equals(definitionId, other.definitionId); } } diff --git a/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/Cancellable.java b/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/Cancellable.java new file mode 100644 index 00000000..fef5b973 --- /dev/null +++ b/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/Cancellable.java @@ -0,0 +1,20 @@ +/* + * 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.impl.scheduler; + +public interface Cancellable { + void cancel(); +} diff --git a/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/CronResolver.java b/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/CronResolver.java new file mode 100644 index 00000000..df254895 --- /dev/null +++ b/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/CronResolver.java @@ -0,0 +1,23 @@ +/* + * 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.impl.scheduler; + +import java.time.Duration; +import java.util.Optional; + +public interface CronResolver { + Optional nextExecution(); +} diff --git a/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/CronResolverFactory.java b/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/CronResolverFactory.java new file mode 100644 index 00000000..4c67f062 --- /dev/null +++ b/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/CronResolverFactory.java @@ -0,0 +1,20 @@ +/* + * 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.impl.scheduler; + +public interface CronResolverFactory { + CronResolver parseCron(String cron); +} diff --git a/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/CronUtilsResolver.java b/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/CronUtilsResolver.java new file mode 100644 index 00000000..fcb7086b --- /dev/null +++ b/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/CronUtilsResolver.java @@ -0,0 +1,36 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.serverlessworkflow.impl.scheduler; + +import com.cronutils.model.Cron; +import com.cronutils.model.time.ExecutionTime; +import java.time.Duration; +import java.time.ZonedDateTime; +import java.util.Optional; + +class CronUtilsResolver implements CronResolver { + + private final ExecutionTime executionTime; + + public CronUtilsResolver(Cron cron) { + this.executionTime = ExecutionTime.forCron(cron); + } + + @Override + public Optional nextExecution() { + return executionTime.timeToNextExecution(ZonedDateTime.now()); + } +} diff --git a/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/CronUtilsResolverFactory.java b/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/CronUtilsResolverFactory.java new file mode 100644 index 00000000..3facca0e --- /dev/null +++ b/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/CronUtilsResolverFactory.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.impl.scheduler; + +import com.cronutils.model.CronType; +import com.cronutils.model.definition.CronDefinitionBuilder; +import com.cronutils.parser.CronParser; + +class CronUtilsResolverFactory implements CronResolverFactory { + + private final CronParser cronParser; + + public CronUtilsResolverFactory() { + this(CronType.UNIX); + } + + public CronUtilsResolverFactory(CronType type) { + this.cronParser = new CronParser(CronDefinitionBuilder.instanceDefinitionFor(type)); + } + + @Override + public CronResolver parseCron(String cron) { + return new CronUtilsResolver(cronParser.parse(cron)); + } +} diff --git a/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/DefaultWorkflowScheduler.java b/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/DefaultWorkflowScheduler.java index 5e3338e3..338fbca7 100644 --- a/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/DefaultWorkflowScheduler.java +++ b/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/DefaultWorkflowScheduler.java @@ -19,20 +19,39 @@ import io.serverlessworkflow.impl.WorkflowDefinition; import io.serverlessworkflow.impl.WorkflowInstance; import io.serverlessworkflow.impl.WorkflowModel; -import io.serverlessworkflow.impl.WorkflowScheduler; import io.serverlessworkflow.impl.events.EventRegistrationBuilderInfo; +import java.time.Duration; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.Function; public class DefaultWorkflowScheduler implements WorkflowScheduler { - private Map> instances = + private final Map> instances = new ConcurrentHashMap<>(); + private final ScheduledExecutorService service; + private final CronResolverFactory cronFactory; + + public DefaultWorkflowScheduler() { + this(Executors.newSingleThreadScheduledExecutor(), new CronUtilsResolverFactory()); + } + + public DefaultWorkflowScheduler( + ScheduledExecutorService service, CronResolverFactory cronFactory) { + this.service = service; + this.cronFactory = cronFactory; + } + @Override public Collection scheduledInstances(WorkflowDefinition definition) { return Collections.unmodifiableCollection(theInstances(definition)); @@ -43,15 +62,93 @@ public ScheduledEventConsumer eventConsumer( WorkflowDefinition definition, Function converter, EventRegistrationBuilderInfo builderInfo) { - return new ScheduledEventConsumer(definition, converter, builderInfo) { - @Override - protected void addScheduledInstance(WorkflowInstance instance) { - theInstances(definition).add(instance); - } - }; + return new ScheduledEventConsumer( + definition, converter, builderInfo, new DefaultScheduledInstanceRunner(definition)); + } + + @Override + public Cancellable scheduleAfter(WorkflowDefinition definition, Duration delay) { + return new ScheduledServiceCancellable( + service.schedule( + new DefaultScheduledInstanceRunner(definition), + delay.toMillis(), + TimeUnit.MILLISECONDS)); + } + + @Override + public Cancellable scheduleEvery(WorkflowDefinition definition, Duration interval) { + long delay = interval.toMillis(); + return new ScheduledServiceCancellable( + service.scheduleAtFixedRate( + new DefaultScheduledInstanceRunner(definition), delay, delay, TimeUnit.MILLISECONDS)); + } + + @Override + public Cancellable scheduleCron(WorkflowDefinition definition, String cron) { + return new CronResolverCancellable(definition, cronFactory.parseCron(cron)); } private Collection theInstances(WorkflowDefinition definition) { return instances.computeIfAbsent(definition, def -> new ArrayList<>()); } + + private class CronResolverCancellable implements Cancellable { + private final WorkflowDefinition definition; + private final CronResolver cronResolver; + + private AtomicReference> nextCron = new AtomicReference<>(); + private AtomicBoolean cancelled = new AtomicBoolean(); + + public CronResolverCancellable(WorkflowDefinition definition, CronResolver cronResolver) { + this.definition = definition; + this.cronResolver = cronResolver; + scheduleNext(); + } + + private void scheduleNext() { + cronResolver + .nextExecution() + .ifPresent( + d -> + nextCron.set( + service.schedule( + new CronResolverIntanceRunner(definition), + d.toMillis(), + TimeUnit.MILLISECONDS))); + } + + @Override + public void cancel() { + cancelled.set(true); + ScheduledFuture toBeCancel = nextCron.get(); + if (toBeCancel != null) { + toBeCancel.cancel(true); + } + } + + private class CronResolverIntanceRunner extends DefaultScheduledInstanceRunner { + protected CronResolverIntanceRunner(WorkflowDefinition definition) { + super(definition); + } + + @Override + public void accept(WorkflowModel model) { + if (!cancelled.get()) { + scheduleNext(); + super.accept(model); + } + } + } + } + + private class DefaultScheduledInstanceRunner extends ScheduledInstanceRunnable { + protected DefaultScheduledInstanceRunner(WorkflowDefinition definition) { + super(definition); + } + + @Override + protected void addScheduledInstance(WorkflowInstance instance) { + theInstances(definition).add(instance); + } + } } diff --git a/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/ScheduledEventConsumer.java b/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/ScheduledEventConsumer.java index 34746355..76e6ccb9 100644 --- a/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/ScheduledEventConsumer.java +++ b/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/ScheduledEventConsumer.java @@ -17,7 +17,6 @@ import io.cloudevents.CloudEvent; import io.serverlessworkflow.impl.WorkflowDefinition; -import io.serverlessworkflow.impl.WorkflowInstance; import io.serverlessworkflow.impl.WorkflowModel; import io.serverlessworkflow.impl.WorkflowModelCollection; import io.serverlessworkflow.impl.events.EventConsumer; @@ -31,22 +30,25 @@ import java.util.Map; import java.util.function.Function; -public abstract class ScheduledEventConsumer implements AutoCloseable { +public class ScheduledEventConsumer implements AutoCloseable { private final Function converter; private final WorkflowDefinition definition; private final EventRegistrationBuilderInfo builderInfo; private final EventConsumer eventConsumer; + private final ScheduledInstanceRunnable instanceRunner; private Map> correlatedEvents; private Collection registrations = new ArrayList<>(); protected ScheduledEventConsumer( WorkflowDefinition definition, Function converter, - EventRegistrationBuilderInfo builderInfo) { + EventRegistrationBuilderInfo builderInfo, + ScheduledInstanceRunnable instanceRunner) { this.definition = definition; this.converter = converter; this.builderInfo = builderInfo; + this.instanceRunner = instanceRunner; this.eventConsumer = definition.application().eventConsumer(); if (builderInfo.registrations().isAnd() && builderInfo.registrations().registrations().size() > 1) { @@ -100,19 +102,13 @@ private boolean satisfyCondition() { protected void start(CloudEvent ce) { WorkflowModelCollection model = definition.application().modelFactory().createCollection(); model.add(converter.apply(ce)); - start(model); + instanceRunner.accept(model); } protected void start(Collection ces) { WorkflowModelCollection model = definition.application().modelFactory().createCollection(); ces.forEach(ce -> model.add(converter.apply(ce))); - start(model); - } - - private void start(WorkflowModel model) { - WorkflowInstance instance = definition.instance(model); - addScheduledInstance(instance); - instance.start(); + instanceRunner.accept(model); } public void close() { @@ -121,6 +117,4 @@ public void close() { } registrations.forEach(eventConsumer::unregister); } - - protected abstract void addScheduledInstance(WorkflowInstance instace); } diff --git a/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/ScheduledInstanceRunnable.java b/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/ScheduledInstanceRunnable.java new file mode 100644 index 00000000..cdb98e58 --- /dev/null +++ b/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/ScheduledInstanceRunnable.java @@ -0,0 +1,44 @@ +/* + * 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.impl.scheduler; + +import io.serverlessworkflow.impl.WorkflowDefinition; +import io.serverlessworkflow.impl.WorkflowInstance; +import io.serverlessworkflow.impl.WorkflowModel; +import java.util.function.Consumer; + +public abstract class ScheduledInstanceRunnable implements Runnable, Consumer { + + protected final WorkflowDefinition definition; + + protected ScheduledInstanceRunnable(WorkflowDefinition definition) { + this.definition = definition; + } + + @Override + public void run() { + accept(definition.application().modelFactory().fromNull()); + } + + @Override + public void accept(WorkflowModel model) { + WorkflowInstance instance = definition.instance(model); + addScheduledInstance(instance); + definition.application().executorService().execute(() -> instance.start()); + } + + protected abstract void addScheduledInstance(WorkflowInstance instance); +} diff --git a/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/ScheduledServiceCancellable.java b/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/ScheduledServiceCancellable.java new file mode 100644 index 00000000..2341f910 --- /dev/null +++ b/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/ScheduledServiceCancellable.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.impl.scheduler; + +import java.util.concurrent.ScheduledFuture; + +class ScheduledServiceCancellable implements Cancellable { + + private final ScheduledFuture cancellable; + + public ScheduledServiceCancellable(ScheduledFuture cancellable) { + this.cancellable = cancellable; + } + + @Override + public void cancel() { + cancellable.cancel(true); + } +} diff --git a/impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowScheduler.java b/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/WorkflowScheduler.java similarity index 54% rename from impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowScheduler.java rename to impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/WorkflowScheduler.java index 84f5b913..27059f1a 100644 --- a/impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowScheduler.java +++ b/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/WorkflowScheduler.java @@ -13,11 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.serverlessworkflow.impl; +package io.serverlessworkflow.impl.scheduler; import io.cloudevents.CloudEvent; +import io.serverlessworkflow.impl.WorkflowDefinition; +import io.serverlessworkflow.impl.WorkflowInstance; +import io.serverlessworkflow.impl.WorkflowModel; import io.serverlessworkflow.impl.events.EventRegistrationBuilderInfo; -import io.serverlessworkflow.impl.scheduler.ScheduledEventConsumer; +import java.time.Duration; import java.util.Collection; import java.util.function.Function; @@ -28,4 +31,19 @@ ScheduledEventConsumer eventConsumer( WorkflowDefinition definition, Function converter, EventRegistrationBuilderInfo info); + + /** + * Periodically instantiate a workflow instance from the given definition at the given interval. + * It continue creating workflow instances till cancelled. + */ + Cancellable scheduleEvery(WorkflowDefinition definition, Duration interval); + + /** Creates one workflow instance after the specified delay. */ + Cancellable scheduleAfter(WorkflowDefinition definition, Duration delay); + + /** + * Creates one or more workflow instances according to the specified Cron expression. It continue + * creating workflow instances till the Cron expression indicates so or it is cancelled + */ + Cancellable scheduleCron(WorkflowDefinition definition, String cron); } diff --git a/impl/pom.xml b/impl/pom.xml index 00d900f9..e4698812 100644 --- a/impl/pom.xml +++ b/impl/pom.xml @@ -14,6 +14,7 @@ 4.0.0 1.6.0 3.1.11 + 9.2.1 @@ -124,6 +125,11 @@ h2-mvstore ${version.com.h2database} + + com.cronutils + cron-utils + ${version.com.cronutils} + diff --git a/impl/test/src/test/java/io/serverlessworkflow/impl/test/SchedulerTest.java b/impl/test/src/test/java/io/serverlessworkflow/impl/test/SchedulerTest.java new file mode 100644 index 00000000..43c19644 --- /dev/null +++ b/impl/test/src/test/java/io/serverlessworkflow/impl/test/SchedulerTest.java @@ -0,0 +1,84 @@ +/* + * 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.impl.test; + +import static io.serverlessworkflow.api.WorkflowReader.readWorkflowFromClasspath; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import io.serverlessworkflow.impl.WorkflowApplication; +import io.serverlessworkflow.impl.WorkflowDefinition; +import java.io.IOException; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.ExecutionException; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +class SchedulerTest { + + private static WorkflowApplication appl; + + @BeforeAll + static void init() throws IOException { + appl = WorkflowApplication.builder().build(); + } + + @AfterAll + static void tearDown() throws IOException { + appl.close(); + } + + @Test + void testAfter() throws IOException, InterruptedException, ExecutionException { + try (WorkflowDefinition def = + appl.workflowDefinition(readWorkflowFromClasspath("workflows-samples/after-start.yaml"))) { + def.instance(Map.of()).start().join(); + assertThat(appl.scheduler().scheduledInstances(def)).isEmpty(); + await() + .pollDelay(Duration.ofMillis(50)) + .atMost(Duration.ofMillis(200)) + .until(() -> appl.scheduler().scheduledInstances(def).size() >= 1); + } + } + + @Test + void testEvery() throws IOException, InterruptedException, ExecutionException { + try (WorkflowDefinition def = + appl.workflowDefinition(readWorkflowFromClasspath("workflows-samples/every-start.yaml"))) { + await() + .pollDelay(Duration.ofMillis(20)) + .atMost(Duration.ofMillis(200)) + .until(() -> appl.scheduler().scheduledInstances(def).size() >= 5); + } + } + + @Test + @Disabled("too long test, since cron cannot be under a minute") + void testCron() throws IOException, InterruptedException, ExecutionException { + try (WorkflowDefinition def = + appl.workflowDefinition(readWorkflowFromClasspath("workflows-samples/cron-start.yaml"))) { + await() + .atMost(Duration.ofMinutes(1).plus(Duration.ofSeconds(10))) + .until(() -> appl.scheduler().scheduledInstances(def).size() == 1); + await() + .atMost(Duration.ofMinutes(1).plus(Duration.ofSeconds(10))) + .until(() -> appl.scheduler().scheduledInstances(def).size() == 2); + } + } +} diff --git a/impl/test/src/test/resources/workflows-samples/after-start.yaml b/impl/test/src/test/resources/workflows-samples/after-start.yaml new file mode 100644 index 00000000..cdefccc4 --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/after-start.yaml @@ -0,0 +1,12 @@ +document: + dsl: '1.0.1' + namespace: test + name: after-driven-schedule + version: '0.1.0' +schedule: + after: + milliseconds: 50 +do: + - recovered: + set: + recovered: true diff --git a/impl/test/src/test/resources/workflows-samples/cron-start.yaml b/impl/test/src/test/resources/workflows-samples/cron-start.yaml new file mode 100644 index 00000000..06dc2cf0 --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/cron-start.yaml @@ -0,0 +1,11 @@ +document: + dsl: '1.0.1' + namespace: test + name: cron-driven-schedule + version: '0.1.0' +schedule: + cron: "* * * * *" +do: + - recovered: + set: + recovered: true diff --git a/impl/test/src/test/resources/workflows-samples/every-start.yaml b/impl/test/src/test/resources/workflows-samples/every-start.yaml new file mode 100644 index 00000000..cf79681b --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/every-start.yaml @@ -0,0 +1,12 @@ +document: + dsl: '1.0.1' + namespace: test + name: every-driven-schedule + version: '0.1.0' +schedule: + every: + milliseconds: 10 +do: + - recovered: + set: + recovered: true From 6838a60491af1e47b54c04cbd855f53010296918 Mon Sep 17 00:00:00 2001 From: Dmitrii Tikhomirov Date: Mon, 10 Nov 2025 08:23:17 -0800 Subject: [PATCH 05/13] Add initial RunContainer Task support (#942) * [Fix #933] Adding timeout support (#963) Signed-off-by: fjtirado Signed-off-by: Dmitrii Tikhomirov * [Fix #932] Workflow scheduler (#966) Signed-off-by: fjtirado Signed-off-by: Dmitrii Tikhomirov * Add initial RunContainer Task support Signed-off-by: Dmitrii Tikhomirov Signed-off-by: Dmitrii Tikhomirov * image pull before run Signed-off-by: Dmitrii Tikhomirov * refactoring + tests Signed-off-by: Dmitrii Tikhomirov * Review comments Signed-off-by: fjtirado Signed-off-by: Dmitrii Tikhomirov * Disable test if docker is not Signed-off-by: fjtirado Signed-off-by: Dmitrii Tikhomirov * Update impl/container/pom.xml Co-authored-by: Ricardo Zanini <1538000+ricardozanini@users.noreply.github.com> Signed-off-by: Dmitrii Tikhomirov * post review Signed-off-by: Dmitrii Tikhomirov * name docker check method Signed-off-by: Dmitrii Tikhomirov --------- Signed-off-by: fjtirado Signed-off-by: Dmitrii Tikhomirov Co-authored-by: Francisco Javier Tirado Sarti <65240126+fjtirado@users.noreply.github.com> Co-authored-by: fjtirado Co-authored-by: Ricardo Zanini <1538000+ricardozanini@users.noreply.github.com> --- impl/container/pom.xml | 33 +++ .../executors/CommandPropertySetter.java | 52 ++++ .../ContainerEnvironmentPropertySetter.java | 59 ++++ .../executors/ContainerPropertySetter.java | 29 ++ .../container/executors/ContainerRunner.java | 266 ++++++++++++++++++ .../executors/LifetimePropertySetter.java | 47 ++++ .../executors/NamePropertySetter.java | 52 ++++ .../executors/PortsPropertySetter.java | 69 +++++ .../executors/RunContainerExecutor.java | 51 ++++ .../executors/VolumesPropertySetter.java | 74 +++++ ...erlessworkflow.impl.executors.RunnableTask | 1 + .../impl/WorkflowUtils.java | 4 + impl/pom.xml | 17 ++ impl/test/pom.xml | 4 + .../impl/test/ContainerTest.java | 263 +++++++++++++++++ .../container/container-cleanup-default.yaml | 12 + .../container/container-cleanup.yaml | 14 + .../container/container-env.yaml | 17 ++ .../container/container-ports.yaml | 18 ++ .../container/container-test-command.yaml | 14 + .../container/container-timeout.yaml | 16 ++ 21 files changed, 1112 insertions(+) create mode 100644 impl/container/pom.xml create mode 100644 impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/CommandPropertySetter.java create mode 100644 impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/ContainerEnvironmentPropertySetter.java create mode 100644 impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/ContainerPropertySetter.java create mode 100644 impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/ContainerRunner.java create mode 100644 impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/LifetimePropertySetter.java create mode 100644 impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/NamePropertySetter.java create mode 100644 impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/PortsPropertySetter.java create mode 100644 impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/RunContainerExecutor.java create mode 100644 impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/VolumesPropertySetter.java create mode 100644 impl/container/src/main/resources/META-INF/services/io.serverlessworkflow.impl.executors.RunnableTask create mode 100644 impl/test/src/test/java/io/serverlessworkflow/impl/test/ContainerTest.java create mode 100644 impl/test/src/test/resources/workflows-samples/container/container-cleanup-default.yaml create mode 100644 impl/test/src/test/resources/workflows-samples/container/container-cleanup.yaml create mode 100644 impl/test/src/test/resources/workflows-samples/container/container-env.yaml create mode 100644 impl/test/src/test/resources/workflows-samples/container/container-ports.yaml create mode 100644 impl/test/src/test/resources/workflows-samples/container/container-test-command.yaml create mode 100644 impl/test/src/test/resources/workflows-samples/container/container-timeout.yaml diff --git a/impl/container/pom.xml b/impl/container/pom.xml new file mode 100644 index 00000000..51ced574 --- /dev/null +++ b/impl/container/pom.xml @@ -0,0 +1,33 @@ + + + 4.0.0 + + io.serverlessworkflow + serverlessworkflow-impl + 8.0.0-SNAPSHOT + + serverlessworkflow-impl-container + Serverless Workflow :: Impl :: Container + + + + io.serverlessworkflow + serverlessworkflow-impl-core + + + io.serverlessworkflow + serverlessworkflow-types + + + com.github.docker-java + docker-java-core + + + com.github.docker-java + docker-java-transport-httpclient5 + + + + \ No newline at end of file diff --git a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/CommandPropertySetter.java b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/CommandPropertySetter.java new file mode 100644 index 00000000..b0af3191 --- /dev/null +++ b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/CommandPropertySetter.java @@ -0,0 +1,52 @@ +/* + * 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.impl.container.executors; + +import static io.serverlessworkflow.impl.WorkflowUtils.isValid; + +import com.github.dockerjava.api.command.CreateContainerCmd; +import io.serverlessworkflow.api.types.Container; +import io.serverlessworkflow.impl.TaskContext; +import io.serverlessworkflow.impl.WorkflowContext; +import io.serverlessworkflow.impl.WorkflowDefinition; +import io.serverlessworkflow.impl.WorkflowModel; +import io.serverlessworkflow.impl.WorkflowUtils; +import io.serverlessworkflow.impl.WorkflowValueResolver; +import java.util.Optional; + +class CommandPropertySetter implements ContainerPropertySetter { + + private Optional> command; + + CommandPropertySetter(WorkflowDefinition definition, Container configuration) { + String commandName = configuration.getCommand(); + command = + isValid(commandName) + ? Optional.of(WorkflowUtils.buildStringFilter(definition.application(), commandName)) + : Optional.empty(); + } + + @Override + public void accept( + CreateContainerCmd containerCmd, + WorkflowContext workflowContext, + TaskContext taskContext, + WorkflowModel model) { + command + .map(c -> c.apply(workflowContext, taskContext, model)) + .ifPresent(c -> containerCmd.withCmd("sh", "-c", c)); + } +} diff --git a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/ContainerEnvironmentPropertySetter.java b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/ContainerEnvironmentPropertySetter.java new file mode 100644 index 00000000..c4f143a3 --- /dev/null +++ b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/ContainerEnvironmentPropertySetter.java @@ -0,0 +1,59 @@ +/* + * 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.impl.container.executors; + +import com.github.dockerjava.api.command.CreateContainerCmd; +import io.serverlessworkflow.api.types.Container; +import io.serverlessworkflow.impl.TaskContext; +import io.serverlessworkflow.impl.WorkflowContext; +import io.serverlessworkflow.impl.WorkflowDefinition; +import io.serverlessworkflow.impl.WorkflowModel; +import io.serverlessworkflow.impl.WorkflowUtils; +import io.serverlessworkflow.impl.WorkflowValueResolver; +import java.util.Map; +import java.util.Optional; + +class ContainerEnvironmentPropertySetter implements ContainerPropertySetter { + + private final Optional>> envResolver; + + ContainerEnvironmentPropertySetter(WorkflowDefinition definition, Container configuration) { + + this.envResolver = + configuration.getEnvironment() != null + && configuration.getEnvironment().getAdditionalProperties() != null + ? Optional.of( + WorkflowUtils.buildMapResolver( + definition.application(), + null, + configuration.getEnvironment().getAdditionalProperties())) + : Optional.empty(); + } + + @Override + public void accept( + CreateContainerCmd command, + WorkflowContext workflowContext, + TaskContext taskContext, + WorkflowModel model) { + envResolver + .map(env -> env.apply(workflowContext, taskContext, model)) + .ifPresent( + envs -> + command.withEnv( + envs.entrySet().stream().map(e -> e.getKey() + "=" + e.getValue()).toList())); + } +} diff --git a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/ContainerPropertySetter.java b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/ContainerPropertySetter.java new file mode 100644 index 00000000..9524a92a --- /dev/null +++ b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/ContainerPropertySetter.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.impl.container.executors; + +import com.github.dockerjava.api.command.CreateContainerCmd; +import io.serverlessworkflow.impl.TaskContext; +import io.serverlessworkflow.impl.WorkflowContext; +import io.serverlessworkflow.impl.WorkflowModel; + +interface ContainerPropertySetter { + abstract void accept( + CreateContainerCmd command, + WorkflowContext workflowContext, + TaskContext taskContext, + WorkflowModel model); +} diff --git a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/ContainerRunner.java b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/ContainerRunner.java new file mode 100644 index 00000000..5c14a3ac --- /dev/null +++ b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/ContainerRunner.java @@ -0,0 +1,266 @@ +/* + * 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.impl.container.executors; + +import static io.serverlessworkflow.api.types.ContainerLifetime.*; +import static io.serverlessworkflow.impl.WorkflowUtils.isValid; + +import com.github.dockerjava.api.DockerClient; +import com.github.dockerjava.api.command.CreateContainerCmd; +import com.github.dockerjava.api.command.CreateContainerResponse; +import com.github.dockerjava.api.command.WaitContainerResultCallback; +import com.github.dockerjava.api.exception.DockerClientException; +import com.github.dockerjava.api.exception.NotFoundException; +import com.github.dockerjava.core.DefaultDockerClientConfig; +import com.github.dockerjava.core.DockerClientImpl; +import com.github.dockerjava.core.NameParser; +import com.github.dockerjava.httpclient5.ApacheDockerHttpClient; +import io.serverlessworkflow.api.types.Container; +import io.serverlessworkflow.api.types.ContainerLifetime; +import io.serverlessworkflow.api.types.TimeoutAfter; +import io.serverlessworkflow.impl.TaskContext; +import io.serverlessworkflow.impl.WorkflowContext; +import io.serverlessworkflow.impl.WorkflowDefinition; +import io.serverlessworkflow.impl.WorkflowModel; +import io.serverlessworkflow.impl.WorkflowUtils; +import io.serverlessworkflow.impl.WorkflowValueResolver; +import java.io.IOException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +class ContainerRunner { + + private static final DefaultDockerClientConfig DEFAULT_CONFIG = + DefaultDockerClientConfig.createDefaultConfigBuilder().build(); + + private static class DockerClientHolder { + private static final DockerClient dockerClient = + DockerClientImpl.getInstance( + DEFAULT_CONFIG, + new ApacheDockerHttpClient.Builder() + .dockerHost(DEFAULT_CONFIG.getDockerHost()) + .build()); + } + + private final Collection propertySetters; + private final Optional> timeout; + private final ContainerCleanupPolicy policy; + private final String containerImage; + + private ContainerRunner(ContainerRunnerBuilder builder) { + this.propertySetters = builder.propertySetters; + this.timeout = Optional.ofNullable(builder.timeout); + this.policy = builder.policy; + this.containerImage = builder.containerImage; + } + + CompletableFuture start( + WorkflowContext workflowContext, TaskContext taskContext, WorkflowModel input) { + return CompletableFuture.supplyAsync( + () -> startSync(workflowContext, taskContext, input), + workflowContext.definition().application().executorService()); + } + + private WorkflowModel startSync( + WorkflowContext workflowContext, TaskContext taskContext, WorkflowModel input) { + Integer exit = executeContainer(workflowContext, taskContext, input); + if (exit == null || exit == 0) { + return input; + } else { + throw mapExitCode(exit); + } + } + + private Integer executeContainer( + WorkflowContext workflowContext, TaskContext taskContext, WorkflowModel input) { + try { + pullImageIfNeeded(containerImage); + CreateContainerCmd containerCommand = + DockerClientHolder.dockerClient.createContainerCmd(containerImage); + propertySetters.forEach(p -> p.accept(containerCommand, workflowContext, taskContext, input)); + return waitAccordingToLifetime( + createAndStartContainer(containerCommand), workflowContext, taskContext, input); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw failed("Container execution failed with exit code " + ie.getMessage()); + } catch (IOException e) { + throw failed("Container execution failed with exit code " + e.getMessage()); + } + } + + private void pullImageIfNeeded(String imageRef) throws InterruptedException { + NameParser.ReposTag rt = NameParser.parseRepositoryTag(imageRef); + DockerClientHolder.dockerClient + .pullImageCmd(NameParser.resolveRepositoryName(imageRef).reposName) + .withTag(WorkflowUtils.isValid(rt.tag) ? rt.tag : "latest") + .start() + .awaitCompletion(); + } + + private String createAndStartContainer(CreateContainerCmd containerCommand) { + CreateContainerResponse resp = containerCommand.exec(); + String id = resp.getId(); + if (!isValid(id)) { + throw new IllegalStateException("Container creation failed: empty ID"); + } + DockerClientHolder.dockerClient.startContainerCmd(id).exec(); + return id; + } + + private Integer waitAccordingToLifetime( + String id, WorkflowContext workflowContext, TaskContext taskContext, WorkflowModel input) + throws IOException { + try (var cb = + DockerClientHolder.dockerClient + .waitContainerCmd(id) + .exec(new WaitContainerResultCallback())) { + if (policy == ContainerCleanupPolicy.EVENTUALLY) { + Duration timeout = + this.timeout + .map(t -> t.apply(workflowContext, taskContext, input)) + .orElse(Duration.ZERO); + try { + Integer exit = cb.awaitStatusCode(timeout.toMillis(), TimeUnit.MILLISECONDS); + safeStop(id); + return exit; + } catch (DockerClientException timeoutOrOther) { + safeStop(id); + } + } else { + return cb.awaitStatusCode(); + } + } catch (NotFoundException e) { + // container already removed + } + return 0; + } + + private boolean isRunning(String id) { + try { + var st = DockerClientHolder.dockerClient.inspectContainerCmd(id).exec().getState(); + return st != null && Boolean.TRUE.equals(st.getRunning()); + } catch (Exception e) { + return false; // must be already removed + } + } + + private void safeStop(String id) { + if (isRunning(id)) { + safeStop(id, Duration.ofSeconds(10)); + try (var cb2 = + DockerClientHolder.dockerClient + .waitContainerCmd(id) + .exec(new WaitContainerResultCallback())) { + cb2.awaitStatusCode(); + safeRemove(id); + } catch (Exception ignore) { + // we can ignore this + } + } else { + safeRemove(id); + } + } + + private void safeStop(String id, Duration timeout) { + try { + DockerClientHolder.dockerClient + .stopContainerCmd(id) + .withTimeout((int) Math.max(1, timeout.toSeconds())) + .exec(); + } catch (Exception ignore) { + // we can ignore this + } + } + + // must be removed because of withAutoRemove(true), but just in case + private void safeRemove(String id) { + try { + DockerClientHolder.dockerClient.removeContainerCmd(id).withForce(true).exec(); + } catch (Exception ignore) { + // we can ignore this + } + } + + private static RuntimeException mapExitCode(int exit) { + return switch (exit) { + case 1 -> failed("General error (exit code 1)"); + case 2 -> failed("Shell syntax error (exit code 2)"); + case 126 -> failed("Command found but not executable (exit code 126)"); + case 127 -> failed("Command not found (exit code 127)"); + case 130 -> failed("Interrupted by SIGINT (exit code 130)"); + case 137 -> failed("Killed by SIGKILL (exit code 137)"); + case 139 -> failed("Segmentation fault (exit code 139)"); + case 143 -> failed("Terminated by SIGTERM (exit code 143)"); + default -> failed("Process exited with code " + exit); + }; + } + + private static RuntimeException failed(String message) { + return new RuntimeException(message); + } + + static ContainerRunnerBuilder builder() { + return new ContainerRunnerBuilder(); + } + + public static class ContainerRunnerBuilder { + private Container container; + private WorkflowDefinition definition; + private WorkflowValueResolver timeout; + private ContainerCleanupPolicy policy; + private String containerImage; + private Collection propertySetters = new ArrayList<>(); + + private ContainerRunnerBuilder() {} + + ContainerRunnerBuilder withContainer(Container container) { + this.container = container; + return this; + } + + public ContainerRunnerBuilder withWorkflowDefinition(WorkflowDefinition definition) { + this.definition = definition; + return this; + } + + ContainerRunner build() { + propertySetters.add(new NamePropertySetter(definition, container)); + propertySetters.add(new CommandPropertySetter(definition, container)); + propertySetters.add(new ContainerEnvironmentPropertySetter(definition, container)); + propertySetters.add(new LifetimePropertySetter(container)); + propertySetters.add(new PortsPropertySetter(container)); + propertySetters.add(new VolumesPropertySetter(definition, container)); + + containerImage = container.getImage(); + if (containerImage == null || container.getImage().isBlank()) { + throw new IllegalArgumentException("Container image must be provided"); + } + ContainerLifetime lifetime = container.getLifetime(); + if (lifetime != null) { + policy = lifetime.getCleanup(); + TimeoutAfter afterTimeout = lifetime.getAfter(); + if (afterTimeout != null) + timeout = WorkflowUtils.fromTimeoutAfter(definition.application(), afterTimeout); + } + + return new ContainerRunner(this); + } + } +} diff --git a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/LifetimePropertySetter.java b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/LifetimePropertySetter.java new file mode 100644 index 00000000..3606ae93 --- /dev/null +++ b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/LifetimePropertySetter.java @@ -0,0 +1,47 @@ +/* + * 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.impl.container.executors; + +import static io.serverlessworkflow.api.types.ContainerLifetime.*; + +import com.github.dockerjava.api.command.CreateContainerCmd; +import io.serverlessworkflow.api.types.Container; +import io.serverlessworkflow.impl.TaskContext; +import io.serverlessworkflow.impl.WorkflowContext; +import io.serverlessworkflow.impl.WorkflowModel; + +class LifetimePropertySetter implements ContainerPropertySetter { + + private final ContainerCleanupPolicy cleanupPolicy; + + LifetimePropertySetter(Container configuration) { + this.cleanupPolicy = + configuration.getLifetime() != null ? configuration.getLifetime().getCleanup() : null; + } + + @Override + public void accept( + CreateContainerCmd command, + WorkflowContext workflowContext, + TaskContext taskContext, + WorkflowModel model) { + if (ContainerCleanupPolicy.ALWAYS.equals(cleanupPolicy)) { + command.getHostConfig().withAutoRemove(true); + } else if (ContainerCleanupPolicy.NEVER.equals(cleanupPolicy)) { + command.getHostConfig().withAutoRemove(false); + } + } +} diff --git a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/NamePropertySetter.java b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/NamePropertySetter.java new file mode 100644 index 00000000..70bc8d83 --- /dev/null +++ b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/NamePropertySetter.java @@ -0,0 +1,52 @@ +/* + * 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.impl.container.executors; + +import static io.serverlessworkflow.impl.WorkflowUtils.isValid; + +import com.github.dockerjava.api.command.CreateContainerCmd; +import io.serverlessworkflow.api.types.Container; +import io.serverlessworkflow.impl.TaskContext; +import io.serverlessworkflow.impl.WorkflowContext; +import io.serverlessworkflow.impl.WorkflowDefinition; +import io.serverlessworkflow.impl.WorkflowModel; +import io.serverlessworkflow.impl.WorkflowUtils; +import io.serverlessworkflow.impl.WorkflowValueResolver; +import java.util.Optional; + +class NamePropertySetter implements ContainerPropertySetter { + + private final Optional> containerName; + + NamePropertySetter(WorkflowDefinition definition, Container container) { + String containerName = container.getName(); + this.containerName = + isValid(containerName) + ? Optional.of(WorkflowUtils.buildStringFilter(definition.application(), containerName)) + : Optional.empty(); + } + + @Override + public void accept( + CreateContainerCmd createContainerCmd, + WorkflowContext workflowContext, + TaskContext taskContext, + WorkflowModel model) { + containerName + .map(c -> c.apply(workflowContext, taskContext, model)) + .ifPresent(createContainerCmd::withName); + } +} diff --git a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/PortsPropertySetter.java b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/PortsPropertySetter.java new file mode 100644 index 00000000..b176b110 --- /dev/null +++ b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/PortsPropertySetter.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.impl.container.executors; + +import com.github.dockerjava.api.command.CreateContainerCmd; +import com.github.dockerjava.api.model.ExposedPort; +import com.github.dockerjava.api.model.Ports; +import io.serverlessworkflow.api.types.Container; +import io.serverlessworkflow.impl.TaskContext; +import io.serverlessworkflow.impl.WorkflowContext; +import io.serverlessworkflow.impl.WorkflowModel; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +class PortsPropertySetter implements ContainerPropertySetter { + + private Ports portBindings = new Ports(); + private List exposed = new ArrayList<>(); + + PortsPropertySetter(Container configuration) { + if (configuration.getPorts() != null + && configuration.getPorts().getAdditionalProperties() != null) { + for (Map.Entry entry : + configuration.getPorts().getAdditionalProperties().entrySet()) { + ExposedPort exposedPort = ExposedPort.tcp(Integer.parseInt(entry.getKey())); + exposed.add(exposedPort); + portBindings.bind(exposedPort, Ports.Binding.bindPort(from(entry.getValue()))); + } + } + } + + @Override + public void accept( + CreateContainerCmd command, + WorkflowContext workflowContext, + TaskContext taskContext, + WorkflowModel model) { + command.withExposedPorts(exposed); + command.getHostConfig().withPortBindings(portBindings); + } + + private static Integer from(Object obj) { + if (obj instanceof Integer number) { + return number; + } else if (obj instanceof Number number) { + return number.intValue(); + } else if (obj instanceof String str) { + return Integer.parseInt(str); + } else if (obj != null) { + return Integer.parseInt(obj.toString()); + } else { + throw new IllegalArgumentException("Null value for port key"); + } + } +} diff --git a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/RunContainerExecutor.java b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/RunContainerExecutor.java new file mode 100644 index 00000000..adf7482b --- /dev/null +++ b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/RunContainerExecutor.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.impl.container.executors; + +import io.serverlessworkflow.api.types.Container; +import io.serverlessworkflow.api.types.RunContainer; +import io.serverlessworkflow.api.types.RunTaskConfiguration; +import io.serverlessworkflow.impl.TaskContext; +import io.serverlessworkflow.impl.WorkflowContext; +import io.serverlessworkflow.impl.WorkflowDefinition; +import io.serverlessworkflow.impl.WorkflowModel; +import io.serverlessworkflow.impl.executors.RunnableTask; +import java.util.concurrent.CompletableFuture; + +public class RunContainerExecutor implements RunnableTask { + + private ContainerRunner containerRunner; + + public void init(RunContainer taskConfiguration, WorkflowDefinition definition) { + Container container = taskConfiguration.getContainer(); + containerRunner = + ContainerRunner.builder() + .withContainer(container) + .withWorkflowDefinition(definition) + .build(); + } + + @Override + public CompletableFuture apply( + WorkflowContext workflowContext, TaskContext taskContext, WorkflowModel input) { + return containerRunner.start(workflowContext, taskContext, input); + } + + @Override + public boolean accept(Class clazz) { + return RunContainer.class.equals(clazz); + } +} diff --git a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/VolumesPropertySetter.java b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/VolumesPropertySetter.java new file mode 100644 index 00000000..63830f34 --- /dev/null +++ b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/VolumesPropertySetter.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.impl.container.executors; + +import com.github.dockerjava.api.command.CreateContainerCmd; +import com.github.dockerjava.api.model.Bind; +import com.github.dockerjava.api.model.Volume; +import io.serverlessworkflow.api.types.Container; +import io.serverlessworkflow.impl.TaskContext; +import io.serverlessworkflow.impl.WorkflowContext; +import io.serverlessworkflow.impl.WorkflowDefinition; +import io.serverlessworkflow.impl.WorkflowModel; +import io.serverlessworkflow.impl.WorkflowUtils; +import io.serverlessworkflow.impl.WorkflowValueResolver; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Map; +import java.util.Objects; + +class VolumesPropertySetter implements ContainerPropertySetter { + + private static record HostContainer( + WorkflowValueResolver host, WorkflowValueResolver container) {} + + private final Collection binds = new ArrayList<>(); + + VolumesPropertySetter(WorkflowDefinition definition, Container configuration) { + if (configuration.getVolumes() != null + && configuration.getVolumes().getAdditionalProperties() != null) { + for (Map.Entry entry : + configuration.getVolumes().getAdditionalProperties().entrySet()) { + binds.add( + new HostContainer( + WorkflowUtils.buildStringFilter(definition.application(), entry.getKey()), + WorkflowUtils.buildStringFilter( + definition.application(), + Objects.requireNonNull( + entry.getValue(), "Volume value must be a not null string") + .toString()))); + } + } + } + + @Override + public void accept( + CreateContainerCmd command, + WorkflowContext workflowContext, + TaskContext taskContext, + WorkflowModel model) { + command + .getHostConfig() + .withBinds( + binds.stream() + .map( + r -> + new Bind( + r.host().apply(workflowContext, taskContext, model), + new Volume(r.container.apply(workflowContext, taskContext, model)))) + .toList()); + } +} diff --git a/impl/container/src/main/resources/META-INF/services/io.serverlessworkflow.impl.executors.RunnableTask b/impl/container/src/main/resources/META-INF/services/io.serverlessworkflow.impl.executors.RunnableTask new file mode 100644 index 00000000..c1450d21 --- /dev/null +++ b/impl/container/src/main/resources/META-INF/services/io.serverlessworkflow.impl.executors.RunnableTask @@ -0,0 +1 @@ +io.serverlessworkflow.impl.container.executors.RunContainerExecutor \ No newline at end of file diff --git a/impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowUtils.java b/impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowUtils.java index 7b6b3403..6fef5a85 100644 --- a/impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowUtils.java +++ b/impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowUtils.java @@ -59,6 +59,10 @@ public static Optional getSchemaValidator( return Optional.empty(); } + public static boolean isValid(String str) { + return str != null && !str.isBlank(); + } + public static Optional buildWorkflowFilter( WorkflowApplication app, InputFrom from) { return from != null diff --git a/impl/pom.xml b/impl/pom.xml index e4698812..c59df5fa 100644 --- a/impl/pom.xml +++ b/impl/pom.xml @@ -15,6 +15,7 @@ 1.6.0 3.1.11 9.2.1 + 3.6.0 @@ -93,6 +94,11 @@ serverlessworkflow-impl-openapi ${project.version} + + io.serverlessworkflow + serverlessworkflow-impl-container + ${project.version} + net.thisptr jackson-jq @@ -130,6 +136,16 @@ cron-utils ${version.com.cronutils} + + com.github.docker-java + docker-java-core + ${version.docker.java} + + + com.github.docker-java + docker-java-transport-httpclient5 + ${version.docker.java} + @@ -145,6 +161,7 @@ model lifecycleevent validation + container test diff --git a/impl/test/pom.xml b/impl/test/pom.xml index 7672bbef..f4e846cb 100644 --- a/impl/test/pom.xml +++ b/impl/test/pom.xml @@ -41,6 +41,10 @@ io.serverlessworkflow serverlessworkflow-impl-openapi + + io.serverlessworkflow + serverlessworkflow-impl-container + org.glassfish.jersey.core jersey-client diff --git a/impl/test/src/test/java/io/serverlessworkflow/impl/test/ContainerTest.java b/impl/test/src/test/java/io/serverlessworkflow/impl/test/ContainerTest.java new file mode 100644 index 00000000..70471a2b --- /dev/null +++ b/impl/test/src/test/java/io/serverlessworkflow/impl/test/ContainerTest.java @@ -0,0 +1,263 @@ +/* + * 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.impl.test; + +import static io.serverlessworkflow.api.WorkflowReader.readWorkflowFromClasspath; +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.github.dockerjava.api.DockerClient; +import com.github.dockerjava.api.command.InspectContainerResponse; +import com.github.dockerjava.api.model.Container; +import com.github.dockerjava.api.model.ExposedPort; +import com.github.dockerjava.api.model.Frame; +import com.github.dockerjava.api.model.Ports; +import com.github.dockerjava.core.DefaultDockerClientConfig; +import com.github.dockerjava.core.DockerClientImpl; +import com.github.dockerjava.core.command.LogContainerResultCallback; +import com.github.dockerjava.httpclient5.ApacheDockerHttpClient; +import io.serverlessworkflow.api.types.Workflow; +import io.serverlessworkflow.impl.WorkflowApplication; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.time.Duration; +import java.util.Arrays; +import java.util.Map; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledIf; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@DisabledIf("isDockerNotAvailable") +public class ContainerTest { + + private static DockerClient dockerClient; + private static Logger logger = LoggerFactory.getLogger(ContainerTest.class); + + { + DefaultDockerClientConfig defaultConfig = + DefaultDockerClientConfig.createDefaultConfigBuilder().build(); + dockerClient = + DockerClientImpl.getInstance( + defaultConfig, + new ApacheDockerHttpClient.Builder().dockerHost(defaultConfig.getDockerHost()).build()); + } + + @SuppressWarnings("unused") + private static boolean isDockerNotAvailable() { + try { + dockerClient.pingCmd().exec(); + return false; + } catch (Exception ex) { + logger.warn("Docker is not running, disabling container test"); + return true; + } + } + + private static WorkflowApplication app; + + @BeforeAll + static void init() { + app = WorkflowApplication.builder().build(); + } + + @AfterAll + static void cleanup() throws IOException { + app.close(); + } + + @Test + public void testContainer() throws IOException, InterruptedException { + Workflow workflow = + readWorkflowFromClasspath("workflows-samples/container/container-test-command.yaml"); + String containerName = "hello-world"; + try { + Map result = + app.workflowDefinition(workflow).instance(Map.of()).start().join().asMap().orElseThrow(); + + String containerId = findContainerIdByName(containerName); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + + dockerClient + .logContainerCmd(containerId) + .withStdOut(true) + .withStdErr(true) + .withTimestamps(true) + .exec( + new LogContainerResultCallback() { + @Override + public void onNext(Frame frame) { + output.writeBytes(frame.getPayload()); + } + }) + .awaitCompletion(); + + assertTrue(output.toString().contains("Hello World")); + assertNotNull(result); + } finally { + dockerClient.removeContainerCmd(findContainerIdByName(containerName)).withForce(true).exec(); + } + } + + @Test + public void testContainerEnv() throws IOException, InterruptedException { + Workflow workflow = readWorkflowFromClasspath("workflows-samples/container/container-env.yaml"); + String containerName = "hello-world-envs"; + + Map input = Map.of("someValue", "Tested"); + + try { + Map result = + app.workflowDefinition(workflow).instance(input).start().join().asMap().orElseThrow(); + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + + dockerClient + .logContainerCmd(findContainerIdByName(containerName)) + .withStdOut(true) + .withStdErr(true) + .withTimestamps(true) + .exec( + new LogContainerResultCallback() { + @Override + public void onNext(Frame frame) { + output.writeBytes(frame.getPayload()); + } + }) + .awaitCompletion(); + assertTrue(output.toString().contains("BAR=FOO")); + assertTrue(output.toString().contains("FOO=Tested")); + assertNotNull(result); + } finally { + dockerClient.removeContainerCmd(findContainerIdByName(containerName)).withForce(true).exec(); + } + } + + @Test + public void testContainerTimeout() throws IOException { + String containerName = "hello-world-timeout"; + try { + Workflow workflow = + readWorkflowFromClasspath("workflows-samples/container/container-timeout.yaml"); + + Map result = + app.workflowDefinition(workflow).instance(Map.of()).start().join().asMap().orElseThrow(); + + String containerId = findContainerIdByName(containerName); + + assertTrue(isContainerGone(containerId)); + assertNotNull(result); + } finally { + dockerClient.removeContainerCmd(findContainerIdByName(containerName)).withForce(true).exec(); + } + } + + @Test + public void testContainerCleanup() throws IOException { + String containerName = "hello-world-cleanup"; + try { + Workflow workflow = + readWorkflowFromClasspath("workflows-samples/container/container-cleanup.yaml"); + + Map result = + app.workflowDefinition(workflow).instance(Map.of()).start().join().asMap().orElseThrow(); + + String containerId = findContainerIdByName(containerName); + assertTrue(isContainerGone(containerId)); + assertNotNull(result); + } finally { + dockerClient.removeContainerCmd(findContainerIdByName(containerName)).withForce(true).exec(); + } + } + + @Test + public void testContainerCleanupDefault() throws IOException { + String containerName = "hello-world-cleanup-default"; + try { + Workflow workflow = + readWorkflowFromClasspath("workflows-samples/container/container-cleanup-default.yaml"); + + Map result = + app.workflowDefinition(workflow).instance(Map.of()).start().join().asMap().orElseThrow(); + String containerId = findContainerIdByName(containerName); + assertFalse(isContainerGone(containerId)); + assertNotNull(result); + } finally { + dockerClient.removeContainerCmd(findContainerIdByName(containerName)).withForce(true).exec(); + } + } + + @Test + void testPortBindings() throws Exception { + Workflow workflow = + readWorkflowFromClasspath("workflows-samples/container/container-ports.yaml"); + String containerName = "hello-world-ports"; + + try { + new Thread( + () -> { + app.workflowDefinition(workflow) + .instance(Map.of()) + .start() + .join() + .asMap() + .orElseThrow(); + }) + .start(); + + await() + .pollInterval(Duration.ofSeconds(1)) + .atMost(Duration.ofSeconds(10)) + .until(() -> findContainerIdByName(containerName) != null); + + String containerId = findContainerIdByName(containerName); + InspectContainerResponse inspect = dockerClient.inspectContainerCmd(containerId).exec(); + Map ports = + inspect.getNetworkSettings().getPorts().getBindings(); + + assertTrue(ports.containsKey(ExposedPort.tcp(8880))); + assertTrue(ports.containsKey(ExposedPort.tcp(8881))); + assertTrue(ports.containsKey(ExposedPort.tcp(8882))); + } finally { + dockerClient.removeContainerCmd(findContainerIdByName(containerName)).withForce(true).exec(); + } + } + + private static String findContainerIdByName(String containerName) { + var containers = dockerClient.listContainersCmd().withShowAll(true).exec(); + + return containers.stream() + .filter( + c -> + c.getNames() != null + && Arrays.stream(c.getNames()).anyMatch(n -> n.equals("/" + containerName))) + .map(Container::getId) + .findFirst() + .orElse(null); + } + + private static boolean isContainerGone(String id) { + if (id == null) { + return true; + } + var containers = dockerClient.listContainersCmd().withShowAll(true).exec(); + return containers.stream().noneMatch(c -> c.getId().startsWith(id)); + } +} diff --git a/impl/test/src/test/resources/workflows-samples/container/container-cleanup-default.yaml b/impl/test/src/test/resources/workflows-samples/container/container-cleanup-default.yaml new file mode 100644 index 00000000..a1a10b80 --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/container/container-cleanup-default.yaml @@ -0,0 +1,12 @@ +document: + dsl: '1.0.2' + namespace: test + name: container-cleanup-default + version: '0.1.0' +do: + - runContainer: + run: + container: + image: busybox:latest + command: echo Hello World + name: hello-world-cleanup-default diff --git a/impl/test/src/test/resources/workflows-samples/container/container-cleanup.yaml b/impl/test/src/test/resources/workflows-samples/container/container-cleanup.yaml new file mode 100644 index 00000000..aa5680f4 --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/container/container-cleanup.yaml @@ -0,0 +1,14 @@ +document: + dsl: '1.0.2' + namespace: test + name: cointaner-cleanup + version: '0.1.0' +do: + - runContainer: + run: + container: + image: busybox:latest + command: echo Hello World + name: hello-world-cleanup + lifetime: + cleanup: always \ No newline at end of file diff --git a/impl/test/src/test/resources/workflows-samples/container/container-env.yaml b/impl/test/src/test/resources/workflows-samples/container/container-env.yaml new file mode 100644 index 00000000..8d50dbab --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/container/container-env.yaml @@ -0,0 +1,17 @@ +document: + dsl: '1.0.2' + namespace: test + name: container-env + version: '0.1.0' +do: + - runContainer: + run: + container: + image: busybox:latest + command: printenv + name: hello-world-envs + lifetime: + cleanup: never + environment: + FOO: ${ .someValue } + BAR: FOO diff --git a/impl/test/src/test/resources/workflows-samples/container/container-ports.yaml b/impl/test/src/test/resources/workflows-samples/container/container-ports.yaml new file mode 100644 index 00000000..3840b876 --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/container/container-ports.yaml @@ -0,0 +1,18 @@ +document: + dsl: '1.0.2' + namespace: test + name: container-ports + version: '0.1.0' +do: + - runContainer: + run: + container: + image: busybox:latest + command: sleep 300 + name: hello-world-ports + ports: + 8880: 8880 + 8881: 8881 + 8882: 8882 + lifetime: + cleanup: never \ No newline at end of file diff --git a/impl/test/src/test/resources/workflows-samples/container/container-test-command.yaml b/impl/test/src/test/resources/workflows-samples/container/container-test-command.yaml new file mode 100644 index 00000000..54887ecb --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/container/container-test-command.yaml @@ -0,0 +1,14 @@ +document: + dsl: '1.0.2' + namespace: test + name: container-test-command + version: '0.1.0' +do: + - runContainer: + run: + container: + image: busybox:3.20 + command: echo Hello World + name: hello-world + lifetime: + cleanup: never diff --git a/impl/test/src/test/resources/workflows-samples/container/container-timeout.yaml b/impl/test/src/test/resources/workflows-samples/container/container-timeout.yaml new file mode 100644 index 00000000..82f18891 --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/container/container-timeout.yaml @@ -0,0 +1,16 @@ +document: + dsl: '1.0.2' + namespace: test + name: container-timeout + version: '0.1.0' +do: + - runContainer: + run: + container: + image: busybox:latest + command: sleep 300 + name: hello-world-timeout + lifetime: + cleanup: eventually + after: + seconds: 1 \ No newline at end of file From a71887364e0fb95c20568941bd6ee604004d73ba Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Nov 2025 11:31:55 -0500 Subject: [PATCH 06/13] Bump org.apache.maven.plugins:maven-release-plugin from 3.1.1 to 3.2.0 (#972) Bumps [org.apache.maven.plugins:maven-release-plugin](https://github.com/apache/maven-release) from 3.1.1 to 3.2.0. - [Release notes](https://github.com/apache/maven-release/releases) - [Commits](https://github.com/apache/maven-release/compare/maven-release-3.1.1...maven-release-3.2.0) --- updated-dependencies: - dependency-name: org.apache.maven.plugins:maven-release-plugin dependency-version: 3.2.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 83bc804b..8fd330fe 100644 --- a/pom.xml +++ b/pom.xml @@ -70,7 +70,7 @@ 3.12.0 3.6.2 0.9.0 - 3.1.1 + 3.2.0 3.3.1 3.5.4 1.7.0 From 002b274052f90948a3e5945e7a78dc85bae15045 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Nov 2025 11:32:06 -0500 Subject: [PATCH 07/13] Bump org.hibernate.validator:hibernate-validator (#971) Bumps [org.hibernate.validator:hibernate-validator](https://github.com/hibernate/hibernate-validator) from 9.0.1.Final to 9.1.0.Final. - [Release notes](https://github.com/hibernate/hibernate-validator/releases) - [Changelog](https://github.com/hibernate/hibernate-validator/blob/main/changelog.txt) - [Commits](https://github.com/hibernate/hibernate-validator/compare/9.0.1.Final...9.1.0.Final) --- updated-dependencies: - dependency-name: org.hibernate.validator:hibernate-validator dependency-version: 9.1.0.Final dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 8fd330fe..a0f1b1ab 100644 --- a/pom.xml +++ b/pom.xml @@ -90,7 +90,7 @@ 6.0.1 5.20.0 2.0.17 - 9.0.1.Final + 9.1.0.Final 6.0.0 1.8.0-beta15 From c33f8ef5fde3f4729abe4e6a4f952042e032c11d Mon Sep 17 00:00:00 2001 From: Matheus Cruz <56329339+mcruzdev@users.noreply.github.com> Date: Tue, 11 Nov 2025 14:13:39 -0300 Subject: [PATCH 08/13] Add support for Run.script (Javascript) (#962) * Introduce Run.script task with JavaScript Signed-off-by: Matheus Cruz * Introduce Run.script task with JavaScript Signed-off-by: Matheus Cruz * Structure changes Signed-off-by: fjtirado * Adjusting poms Signed-off-by: fjtirado * Adjusting poms #2 Signed-off-by: fjtirado --------- Signed-off-by: Matheus Cruz Signed-off-by: fjtirado Co-authored-by: fjtirado Co-authored-by: Francisco Javier Tirado Sarti <65240126+fjtirado@users.noreply.github.com> --- .../impl/executors/RunScriptExecutor.java | 127 ++++++++++++ .../impl/scripts/ScriptContext.java | 26 +++ .../impl/scripts/ScriptLanguageId.java | 47 +++++ .../impl/scripts/ScriptRunner.java | 39 ++++ ...erlessworkflow.impl.executors.RunnableTask | 3 +- impl/pom.xml | 24 ++- impl/script-js/pom.xml | 25 +++ .../script/js/JavaScriptScriptTaskRunner.java | 145 ++++++++++++++ ...rverlessworkflow.impl.scripts.ScriptRunner | 1 + impl/test/pom.xml | 4 + .../impl/test/RunScriptJavaScriptTest.java | 186 ++++++++++++++++++ .../run-script/console-log-args.yaml | 14 ++ .../run-script/console-log-envs.yaml | 14 ++ .../console-log-external-source.yaml | 13 ++ .../run-script/console-log-not-awaiting.yaml | 13 ++ .../run-script/console-log.yaml | 12 ++ .../function-with-syntax-error.yaml | 19 ++ .../run-script/function-with-throw-all.yaml | 18 ++ .../run-script/function-with-throw.yaml | 18 ++ .../workflows-samples/run-script/script.js | 1 + 20 files changed, 745 insertions(+), 4 deletions(-) create mode 100644 impl/core/src/main/java/io/serverlessworkflow/impl/executors/RunScriptExecutor.java create mode 100644 impl/core/src/main/java/io/serverlessworkflow/impl/scripts/ScriptContext.java create mode 100644 impl/core/src/main/java/io/serverlessworkflow/impl/scripts/ScriptLanguageId.java create mode 100644 impl/core/src/main/java/io/serverlessworkflow/impl/scripts/ScriptRunner.java create mode 100644 impl/script-js/pom.xml create mode 100644 impl/script-js/src/main/java/io/serverlessworkflow/impl/executors/script/js/JavaScriptScriptTaskRunner.java create mode 100644 impl/script-js/src/main/resources/META-INF/services/io.serverlessworkflow.impl.scripts.ScriptRunner create mode 100644 impl/test/src/test/java/io/serverlessworkflow/impl/test/RunScriptJavaScriptTest.java create mode 100644 impl/test/src/test/resources/workflows-samples/run-script/console-log-args.yaml create mode 100644 impl/test/src/test/resources/workflows-samples/run-script/console-log-envs.yaml create mode 100644 impl/test/src/test/resources/workflows-samples/run-script/console-log-external-source.yaml create mode 100644 impl/test/src/test/resources/workflows-samples/run-script/console-log-not-awaiting.yaml create mode 100644 impl/test/src/test/resources/workflows-samples/run-script/console-log.yaml create mode 100644 impl/test/src/test/resources/workflows-samples/run-script/function-with-syntax-error.yaml create mode 100644 impl/test/src/test/resources/workflows-samples/run-script/function-with-throw-all.yaml create mode 100644 impl/test/src/test/resources/workflows-samples/run-script/function-with-throw.yaml create mode 100644 impl/test/src/test/resources/workflows-samples/run-script/script.js diff --git a/impl/core/src/main/java/io/serverlessworkflow/impl/executors/RunScriptExecutor.java b/impl/core/src/main/java/io/serverlessworkflow/impl/executors/RunScriptExecutor.java new file mode 100644 index 00000000..1f456e10 --- /dev/null +++ b/impl/core/src/main/java/io/serverlessworkflow/impl/executors/RunScriptExecutor.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.impl.executors; + +import io.serverlessworkflow.api.types.RunScript; +import io.serverlessworkflow.api.types.RunTaskConfiguration; +import io.serverlessworkflow.api.types.Script; +import io.serverlessworkflow.api.types.ScriptUnion; +import io.serverlessworkflow.impl.TaskContext; +import io.serverlessworkflow.impl.WorkflowApplication; +import io.serverlessworkflow.impl.WorkflowContext; +import io.serverlessworkflow.impl.WorkflowDefinition; +import io.serverlessworkflow.impl.WorkflowModel; +import io.serverlessworkflow.impl.WorkflowUtils; +import io.serverlessworkflow.impl.WorkflowValueResolver; +import io.serverlessworkflow.impl.resources.ResourceLoaderUtils; +import io.serverlessworkflow.impl.scripts.ScriptContext; +import io.serverlessworkflow.impl.scripts.ScriptLanguageId; +import io.serverlessworkflow.impl.scripts.ScriptRunner; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.ServiceLoader; +import java.util.concurrent.CompletableFuture; + +public class RunScriptExecutor implements RunnableTask { + + private Optional>> environmentExpr; + + private Optional>> argumentExpr; + + private WorkflowValueResolver codeSupplier; + private boolean isAwait; + private RunTaskConfiguration.ProcessReturnType returnType; + private ScriptRunner taskRunner; + + @Override + public void init(RunScript taskConfiguration, WorkflowDefinition definition) { + ScriptUnion scriptUnion = taskConfiguration.getScript(); + Script script = scriptUnion.get(); + ScriptLanguageId language = ScriptLanguageId.from(script.getLanguage()); + + this.taskRunner = + ServiceLoader.load(ScriptRunner.class).stream() + .map(ServiceLoader.Provider::get) + .filter(s -> s.identifier().equals(language)) + .findFirst() + .orElseThrow( + () -> + new IllegalStateException( + "No script runner implementation found for language " + language)); + + this.isAwait = taskConfiguration.isAwait(); + + this.returnType = taskConfiguration.getReturn(); + + WorkflowApplication application = definition.application(); + this.environmentExpr = + script.getEnvironment() != null && script.getEnvironment().getAdditionalProperties() != null + ? Optional.of( + WorkflowUtils.buildMapResolver( + application, null, script.getEnvironment().getAdditionalProperties())) + : Optional.empty(); + + this.argumentExpr = + script.getArguments() != null && script.getArguments().getAdditionalProperties() != null + ? Optional.of( + WorkflowUtils.buildMapResolver( + application, null, script.getArguments().getAdditionalProperties())) + : Optional.empty(); + + this.codeSupplier = + scriptUnion.getInlineScript() != null + ? WorkflowUtils.buildStringFilter(application, scriptUnion.getInlineScript().getCode()) + : (w, t, m) -> + definition + .resourceLoader() + .load( + Objects.requireNonNull( + scriptUnion.getExternalScript(), + "External script is required if inline script was not set") + .getSource(), + ResourceLoaderUtils::readString, + w, + t, + m); + } + + @Override + public CompletableFuture apply( + WorkflowContext workflowContext, TaskContext taskContext, WorkflowModel input) { + return CompletableFuture.supplyAsync( + () -> + taskRunner.runScript( + new ScriptContext( + argumentExpr + .map(m -> m.apply(workflowContext, taskContext, input)) + .orElse(Map.of()), + environmentExpr + .map(m -> m.apply(workflowContext, taskContext, input)) + .orElse(Map.of()), + codeSupplier.apply(workflowContext, taskContext, input), + isAwait, + returnType), + workflowContext, + taskContext, + input)); + } + + @Override + public boolean accept(Class clazz) { + return RunScript.class.equals(clazz); + } +} diff --git a/impl/core/src/main/java/io/serverlessworkflow/impl/scripts/ScriptContext.java b/impl/core/src/main/java/io/serverlessworkflow/impl/scripts/ScriptContext.java new file mode 100644 index 00000000..68f6692d --- /dev/null +++ b/impl/core/src/main/java/io/serverlessworkflow/impl/scripts/ScriptContext.java @@ -0,0 +1,26 @@ +/* + * 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.impl.scripts; + +import io.serverlessworkflow.api.types.RunTaskConfiguration; +import java.util.Map; + +public record ScriptContext( + Map args, + Map envs, + String code, + boolean isAwait, + RunTaskConfiguration.ProcessReturnType returnType) {} diff --git a/impl/core/src/main/java/io/serverlessworkflow/impl/scripts/ScriptLanguageId.java b/impl/core/src/main/java/io/serverlessworkflow/impl/scripts/ScriptLanguageId.java new file mode 100644 index 00000000..70cf0fb6 --- /dev/null +++ b/impl/core/src/main/java/io/serverlessworkflow/impl/scripts/ScriptLanguageId.java @@ -0,0 +1,47 @@ +/* + * 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.impl.scripts; + +import java.util.Arrays; + +public enum ScriptLanguageId { + JS("js"), + PYTHON("python"); + + private final String lang; + + ScriptLanguageId(String lang) { + this.lang = lang; + } + + public String getLang() { + return lang; + } + + public static ScriptLanguageId from(String lang) { + for (ScriptLanguageId l : ScriptLanguageId.values()) { + if (l.getLang().equalsIgnoreCase(lang)) { + return l; + } + } + throw new IllegalStateException( + "Unsupported script language: " + + lang + + ". Supported languages are: " + + Arrays.toString( + Arrays.stream(ScriptLanguageId.values()).map(ScriptLanguageId::getLang).toArray())); + } +} diff --git a/impl/core/src/main/java/io/serverlessworkflow/impl/scripts/ScriptRunner.java b/impl/core/src/main/java/io/serverlessworkflow/impl/scripts/ScriptRunner.java new file mode 100644 index 00000000..f2795352 --- /dev/null +++ b/impl/core/src/main/java/io/serverlessworkflow/impl/scripts/ScriptRunner.java @@ -0,0 +1,39 @@ +/* + * 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.impl.scripts; + +import io.serverlessworkflow.impl.ServicePriority; +import io.serverlessworkflow.impl.TaskContext; +import io.serverlessworkflow.impl.WorkflowContext; +import io.serverlessworkflow.impl.WorkflowModel; +import io.serverlessworkflow.impl.executors.RunScriptExecutor; + +/** Represents a script task that executes a script in a specific scripting language. */ +public interface ScriptRunner extends ServicePriority { + + /** + * The scripting language supported by this script task runner. + * + * @return the scripting language as {@link RunScriptExecutor.LanguageId} enum. + */ + ScriptLanguageId identifier(); + + WorkflowModel runScript( + ScriptContext script, + WorkflowContext workflowContext, + TaskContext taskContext, + WorkflowModel input); +} diff --git a/impl/core/src/main/resources/META-INF/services/io.serverlessworkflow.impl.executors.RunnableTask b/impl/core/src/main/resources/META-INF/services/io.serverlessworkflow.impl.executors.RunnableTask index 7d2be4f9..ea1bb37e 100644 --- a/impl/core/src/main/resources/META-INF/services/io.serverlessworkflow.impl.executors.RunnableTask +++ b/impl/core/src/main/resources/META-INF/services/io.serverlessworkflow.impl.executors.RunnableTask @@ -1,2 +1,3 @@ io.serverlessworkflow.impl.executors.RunWorkflowExecutor -io.serverlessworkflow.impl.executors.RunShellExecutor \ No newline at end of file +io.serverlessworkflow.impl.executors.RunShellExecutor +io.serverlessworkflow.impl.executors.RunScriptExecutor \ No newline at end of file diff --git a/impl/pom.xml b/impl/pom.xml index c59df5fa..49972c4b 100644 --- a/impl/pom.xml +++ b/impl/pom.xml @@ -16,6 +16,7 @@ 3.1.11 9.2.1 3.6.0 + 23.1.1 @@ -130,12 +131,17 @@ com.h2database h2-mvstore ${version.com.h2database} - - + + + io.serverlessworkflow + serverlessworkflow-impl-script-js + ${project.version} + + com.cronutils cron-utils ${version.com.cronutils} - + com.github.docker-java docker-java-core @@ -146,6 +152,17 @@ docker-java-transport-httpclient5 ${version.docker.java} + + org.graalvm.polyglot + js + ${version.org.graalvm.polyglot} + pom + + + org.graalvm.polyglot + polyglot + ${version.org.graalvm.polyglot} + @@ -163,5 +180,6 @@ validation container test + script-js diff --git a/impl/script-js/pom.xml b/impl/script-js/pom.xml new file mode 100644 index 00000000..1101de3f --- /dev/null +++ b/impl/script-js/pom.xml @@ -0,0 +1,25 @@ + + 4.0.0 + + io.serverlessworkflow + serverlessworkflow-impl + 8.0.0-SNAPSHOT + + serverlessworkflow-impl-script-js + Serverless Workflow :: Impl :: Script JavaScript + + + io.serverlessworkflow + serverlessworkflow-impl-core + + + org.graalvm.polyglot + polyglot + + + org.graalvm.polyglot + js + pom + + + \ No newline at end of file diff --git a/impl/script-js/src/main/java/io/serverlessworkflow/impl/executors/script/js/JavaScriptScriptTaskRunner.java b/impl/script-js/src/main/java/io/serverlessworkflow/impl/executors/script/js/JavaScriptScriptTaskRunner.java new file mode 100644 index 00000000..12540048 --- /dev/null +++ b/impl/script-js/src/main/java/io/serverlessworkflow/impl/executors/script/js/JavaScriptScriptTaskRunner.java @@ -0,0 +1,145 @@ +/* + * 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.impl.executors.script.js; + +import io.serverlessworkflow.api.types.RunTaskConfiguration; +import io.serverlessworkflow.impl.TaskContext; +import io.serverlessworkflow.impl.WorkflowApplication; +import io.serverlessworkflow.impl.WorkflowContext; +import io.serverlessworkflow.impl.WorkflowError; +import io.serverlessworkflow.impl.WorkflowException; +import io.serverlessworkflow.impl.WorkflowModel; +import io.serverlessworkflow.impl.WorkflowModelFactory; +import io.serverlessworkflow.impl.executors.ProcessResult; +import io.serverlessworkflow.impl.scripts.ScriptContext; +import io.serverlessworkflow.impl.scripts.ScriptLanguageId; +import io.serverlessworkflow.impl.scripts.ScriptRunner; +import java.io.ByteArrayOutputStream; +import java.util.Map; +import java.util.function.Supplier; +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.PolyglotException; +import org.graalvm.polyglot.Source; +import org.graalvm.polyglot.Value; + +/** + * JavaScript implementation of the {@link ScriptRunner} interface that executes JavaScript scripts + * using GraalVM Polyglot API. + */ +public class JavaScriptScriptTaskRunner implements ScriptRunner { + + @Override + public ScriptLanguageId identifier() { + return ScriptLanguageId.JS; + } + + @Override + public WorkflowModel runScript( + ScriptContext script, + WorkflowContext workflowContext, + TaskContext taskContext, + WorkflowModel input) { + WorkflowApplication application = workflowContext.definition().application(); + ByteArrayOutputStream stderr = new ByteArrayOutputStream(); + ByteArrayOutputStream stdout = new ByteArrayOutputStream(); + + try (Context ctx = + Context.newBuilder() + .err(stderr) + .out(stdout) + .useSystemExit(true) + .allowCreateProcess(false) + .option("engine.WarnInterpreterOnly", "false") + .build()) { + + script + .args() + .forEach( + (key, val) -> { + ctx.getBindings(identifier().getLang()).putMember(key, val); + }); + + configureProcessEnv(ctx, script.envs()); + + if (!script.isAwait()) { + application + .executorService() + .submit( + () -> { + ctx.eval(identifier().getLang(), script.code()); + }); + return application.modelFactory().fromAny(input); + } + + ctx.eval(Source.create(identifier().getLang(), script.code())); + + return modelFromOutput( + script.returnType(), application.modelFactory(), stdout, () -> stderr.toString()); + } catch (PolyglotException e) { + if (e.getExitStatus() != 0 || e.isSyntaxError()) { + throw new WorkflowException(WorkflowError.runtime(taskContext, e).build()); + } else { + return modelFromOutput( + script.returnType(), application.modelFactory(), stdout, () -> buildStderr(e, stderr)); + } + } + } + + private WorkflowModel modelFromOutput( + RunTaskConfiguration.ProcessReturnType returnType, + WorkflowModelFactory modelFactory, + ByteArrayOutputStream stdout, + Supplier stderr) { + return switch (returnType) { + case ALL -> + modelFactory.fromAny(new ProcessResult(0, stdout.toString().trim(), stderr.get().trim())); + case NONE -> modelFactory.fromNull(); + case CODE -> modelFactory.from(0); + case STDOUT -> modelFactory.from(stdout.toString().trim()); + case STDERR -> modelFactory.from(stderr.get().trim()); + }; + } + + /* + * Gets the stderr message from the PolyglotException or the stderr stream. + * + * @param e the {@link PolyglotException} thrown during script execution + * @param stderr the stderr stream + * @return the stderr message + */ + private String buildStderr(PolyglotException e, ByteArrayOutputStream stderr) { + String err = stderr.toString(); + return err.isBlank() ? e.getMessage() : err.trim(); + } + + /* + * Configures the process.env object in the JavaScript context with the provided environment + * variables. + * + * @param context the GraalVM context + * @param envs the environment variables to set + */ + private void configureProcessEnv(Context context, Map envs) { + String js = ScriptLanguageId.JS.getLang(); + Value bindings = context.getBindings(js); + Value process = context.eval(js, "({ env: {} })"); + + for (var entry : envs.entrySet()) { + process.getMember("env").putMember(entry.getKey(), entry.getValue()); + } + bindings.putMember("process", process); + } +} diff --git a/impl/script-js/src/main/resources/META-INF/services/io.serverlessworkflow.impl.scripts.ScriptRunner b/impl/script-js/src/main/resources/META-INF/services/io.serverlessworkflow.impl.scripts.ScriptRunner new file mode 100644 index 00000000..0e3bb877 --- /dev/null +++ b/impl/script-js/src/main/resources/META-INF/services/io.serverlessworkflow.impl.scripts.ScriptRunner @@ -0,0 +1 @@ +io.serverlessworkflow.impl.executors.script.js.JavaScriptScriptTaskRunner \ No newline at end of file diff --git a/impl/test/pom.xml b/impl/test/pom.xml index f4e846cb..444ae5c1 100644 --- a/impl/test/pom.xml +++ b/impl/test/pom.xml @@ -33,6 +33,10 @@ io.serverlessworkflow serverlessworkflow-impl-jackson-jwt + + io.serverlessworkflow + serverlessworkflow-impl-script-js + org.glassfish.jersey.media jersey-media-json-jackson diff --git a/impl/test/src/test/java/io/serverlessworkflow/impl/test/RunScriptJavaScriptTest.java b/impl/test/src/test/java/io/serverlessworkflow/impl/test/RunScriptJavaScriptTest.java new file mode 100644 index 00000000..24832ebf --- /dev/null +++ b/impl/test/src/test/java/io/serverlessworkflow/impl/test/RunScriptJavaScriptTest.java @@ -0,0 +1,186 @@ +/* + * 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.impl.test; + +import io.serverlessworkflow.api.WorkflowReader; +import io.serverlessworkflow.api.types.Workflow; +import io.serverlessworkflow.impl.WorkflowApplication; +import io.serverlessworkflow.impl.WorkflowModel; +import io.serverlessworkflow.impl.executors.ProcessResult; +import java.io.IOException; +import java.util.Map; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.assertj.core.api.Assertions; +import org.assertj.core.api.SoftAssertions; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class RunScriptJavaScriptTest { + + private MockWebServer fileServer; + + @BeforeEach + void setUp() throws IOException { + fileServer = new MockWebServer(); + fileServer.start(8886); + } + + @AfterEach + void tearDown() throws IOException { + fileServer.shutdown(); + } + + @Test + void testConsoleLog() throws IOException { + Workflow workflow = + WorkflowReader.readWorkflowFromClasspath("workflows-samples/run-script/console-log.yaml"); + try (WorkflowApplication appl = WorkflowApplication.builder().build()) { + WorkflowModel model = appl.workflowDefinition(workflow).instance(Map.of()).start().join(); + + SoftAssertions.assertSoftly( + softly -> { + softly.assertThat(model.asText()).isPresent(); + softly.assertThat(model.asText().get()).isEqualTo("hello from script"); + }); + } + } + + @Test + void testConsoleLogWithArgs() throws IOException { + Workflow workflow = + WorkflowReader.readWorkflowFromClasspath( + "workflows-samples/run-script/console-log-args.yaml"); + try (WorkflowApplication appl = WorkflowApplication.builder().build()) { + WorkflowModel model = appl.workflowDefinition(workflow).instance(Map.of()).start().join(); + + SoftAssertions.assertSoftly( + softly -> { + softly.assertThat(model.asText()).isPresent(); + softly.assertThat(model.asText().get()).isEqualTo("Hello, world!"); + }); + } + } + + @Test + void testConsoleLogWithEnvs() throws IOException { + Workflow workflow = + WorkflowReader.readWorkflowFromClasspath( + "workflows-samples/run-script/console-log-envs.yaml"); + try (WorkflowApplication appl = WorkflowApplication.builder().build()) { + WorkflowModel model = appl.workflowDefinition(workflow).instance(Map.of()).start().join(); + + SoftAssertions.assertSoftly( + softly -> { + softly.assertThat(model.asText()).isPresent(); + softly + .assertThat(model.asText().get()) + .isEqualTo("Running JavaScript code using Serverless Workflow!"); + }); + } + } + + @Test + void testConsoleLogWithExternalSource() throws IOException { + Workflow workflow = + WorkflowReader.readWorkflowFromClasspath( + "workflows-samples/run-script/console-log-external-source.yaml"); + + fileServer.enqueue( + new MockResponse() + .setBody( + """ + console.log("hello from script"); + """) + .setHeader("Content-Type", "application/javascript") + .setResponseCode(200)); + + try (WorkflowApplication appl = WorkflowApplication.builder().build()) { + WorkflowModel model = appl.workflowDefinition(workflow).instance(Map.of()).start().join(); + + SoftAssertions.assertSoftly( + softly -> { + softly.assertThat(model.asText()).isPresent(); + softly.assertThat(model.asText().get()).isEqualTo("hello from script"); + }); + } + } + + @Test + void testFunctionThrowingError() throws IOException { + Workflow workflow = + WorkflowReader.readWorkflowFromClasspath( + "workflows-samples/run-script/function-with-throw.yaml"); + try (WorkflowApplication appl = WorkflowApplication.builder().build()) { + WorkflowModel model = appl.workflowDefinition(workflow).instance(Map.of()).start().join(); + + SoftAssertions.assertSoftly( + softly -> { + softly.assertThat(model.asText()).isPresent(); + softly.assertThat(model.asText().get()).isEqualTo("Error: This is a test error"); + }); + } + } + + @Test + void testFunctionThrowingErrorAndReturnAll() throws IOException { + Workflow workflow = + WorkflowReader.readWorkflowFromClasspath( + "workflows-samples/run-script/function-with-throw-all.yaml"); + try (WorkflowApplication appl = WorkflowApplication.builder().build()) { + WorkflowModel model = appl.workflowDefinition(workflow).instance(Map.of()).start().join(); + + SoftAssertions.assertSoftly( + softly -> { + ProcessResult r = model.as(ProcessResult.class).orElseThrow(); + softly.assertThat(r.stderr()).isEqualTo("Error: This is a test error"); + softly.assertThat(r.stdout()).isEqualTo("logged before the 'throw' statement"); + softly.assertThat(r.code()).isEqualTo(0); + }); + } + } + + @Test + void testFunctionWithSyntaxError() throws IOException { + Workflow workflow = + WorkflowReader.readWorkflowFromClasspath( + "workflows-samples/run-script/function-with-syntax-error.yaml"); + try (WorkflowApplication appl = WorkflowApplication.builder().build()) { + Assertions.assertThatThrownBy( + () -> { + appl.workflowDefinition(workflow).instance(Map.of()).start().join(); + }) + .hasMessageContaining("SyntaxError"); + } + } + + @Test + void testConsoleLogNotAwaiting() throws IOException { + Workflow workflow = + WorkflowReader.readWorkflowFromClasspath( + "workflows-samples/run-script/console-log-not-awaiting.yaml"); + try (WorkflowApplication appl = WorkflowApplication.builder().build()) { + Map input = Map.of("hello", "world"); + + WorkflowModel model = appl.workflowDefinition(workflow).instance(input).start().join(); + + Map output = model.asMap().orElseThrow(); + + Assertions.assertThat(output).isEqualTo(input); + } + } +} diff --git a/impl/test/src/test/resources/workflows-samples/run-script/console-log-args.yaml b/impl/test/src/test/resources/workflows-samples/run-script/console-log-args.yaml new file mode 100644 index 00000000..c51681ea --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/run-script/console-log-args.yaml @@ -0,0 +1,14 @@ +document: + dsl: '1.0.2' + namespace: test + name: run-script-example + version: '0.1.0' +do: + - runScript: + run: + script: + language: js + arguments: + greetings: Hello, world! + code: > + console.log(greetings) \ No newline at end of file diff --git a/impl/test/src/test/resources/workflows-samples/run-script/console-log-envs.yaml b/impl/test/src/test/resources/workflows-samples/run-script/console-log-envs.yaml new file mode 100644 index 00000000..36684b2b --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/run-script/console-log-envs.yaml @@ -0,0 +1,14 @@ +document: + dsl: '1.0.2' + namespace: test + name: run-script-example + version: '0.1.0' +do: + - runScript: + run: + script: + language: js + code: > + console.log(`Running ${process.env.LANGUAGE} code using Serverless Workflow!`); + environment: + LANGUAGE: JavaScript \ No newline at end of file diff --git a/impl/test/src/test/resources/workflows-samples/run-script/console-log-external-source.yaml b/impl/test/src/test/resources/workflows-samples/run-script/console-log-external-source.yaml new file mode 100644 index 00000000..5ab9ba4d --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/run-script/console-log-external-source.yaml @@ -0,0 +1,13 @@ +document: + dsl: '1.0.2' + namespace: test + name: run-script-example + version: '0.1.0' +do: + - runScript: + run: + script: + language: js + source: + endpoint: + uri: http://localhost:8886/script.js diff --git a/impl/test/src/test/resources/workflows-samples/run-script/console-log-not-awaiting.yaml b/impl/test/src/test/resources/workflows-samples/run-script/console-log-not-awaiting.yaml new file mode 100644 index 00000000..c67adb64 --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/run-script/console-log-not-awaiting.yaml @@ -0,0 +1,13 @@ +document: + dsl: '1.0.2' + namespace: test + name: run-script-example + version: '0.1.0' +do: + - runScript: + run: + script: + language: js + code: > + console.log("hello from script"); + await: false diff --git a/impl/test/src/test/resources/workflows-samples/run-script/console-log.yaml b/impl/test/src/test/resources/workflows-samples/run-script/console-log.yaml new file mode 100644 index 00000000..79c5013a --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/run-script/console-log.yaml @@ -0,0 +1,12 @@ +document: + dsl: '1.0.2' + namespace: test + name: run-script-example + version: '0.1.0' +do: + - runScript: + run: + script: + language: js + code: > + console.log("hello from script"); diff --git a/impl/test/src/test/resources/workflows-samples/run-script/function-with-syntax-error.yaml b/impl/test/src/test/resources/workflows-samples/run-script/function-with-syntax-error.yaml new file mode 100644 index 00000000..75bd074d --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/run-script/function-with-syntax-error.yaml @@ -0,0 +1,19 @@ +document: + dsl: '1.0.2' + namespace: test + name: run-script-example + version: '0.1.0' +do: + - runScript: + run: + script: + language: js + code: > + // there is no reserved word func in JavaScript, it should be function + func hello() { + console.log("hello from script"); + throw new Error("This is a test error"); + } + + hello(); + return: stderr diff --git a/impl/test/src/test/resources/workflows-samples/run-script/function-with-throw-all.yaml b/impl/test/src/test/resources/workflows-samples/run-script/function-with-throw-all.yaml new file mode 100644 index 00000000..c6e6cf3f --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/run-script/function-with-throw-all.yaml @@ -0,0 +1,18 @@ +document: + dsl: '1.0.2' + namespace: test + name: run-script-example + version: '0.1.0' +do: + - runScript: + run: + script: + language: js + code: > + function hello() { + console.log("logged before the 'throw' statement"); + throw new Error("This is a test error"); + } + + hello(); + return: all diff --git a/impl/test/src/test/resources/workflows-samples/run-script/function-with-throw.yaml b/impl/test/src/test/resources/workflows-samples/run-script/function-with-throw.yaml new file mode 100644 index 00000000..89236129 --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/run-script/function-with-throw.yaml @@ -0,0 +1,18 @@ +document: + dsl: '1.0.2' + namespace: test + name: run-script-example + version: '0.1.0' +do: + - runScript: + run: + script: + language: js + code: > + function hello() { + console.log("hello from script"); + throw new Error("This is a test error"); + } + + hello(); + return: stderr diff --git a/impl/test/src/test/resources/workflows-samples/run-script/script.js b/impl/test/src/test/resources/workflows-samples/run-script/script.js new file mode 100644 index 00000000..f7b8bd8c --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/run-script/script.js @@ -0,0 +1 @@ +console.log("hello from script"); \ No newline at end of file From 66a9ab295ab3f89018cdcb83161152714fe5edb5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 12 Nov 2025 12:20:30 -0500 Subject: [PATCH 09/13] Bump version.org.glassfish.jersey from 3.1.11 to 4.0.0 (#970) Bumps `version.org.glassfish.jersey` from 3.1.11 to 4.0.0. Updates `org.glassfish.jersey.core:jersey-client` from 3.1.11 to 4.0.0 Updates `org.glassfish.jersey.media:jersey-media-json-jackson` from 3.1.11 to 4.0.0 --- updated-dependencies: - dependency-name: org.glassfish.jersey.core:jersey-client dependency-version: 4.0.0 dependency-type: direct:development update-type: version-update:semver-major - dependency-name: org.glassfish.jersey.media:jersey-media-json-jackson dependency-version: 4.0.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- examples/pom.xml | 2 +- impl/pom.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/pom.xml b/examples/pom.xml index eb1074f9..83917870 100644 --- a/examples/pom.xml +++ b/examples/pom.xml @@ -9,7 +9,7 @@ Serverless Workflow :: Examples serverlessworkflow-examples - 3.1.11 + 4.0.0 pom diff --git a/impl/pom.xml b/impl/pom.xml index 49972c4b..36a33d6c 100644 --- a/impl/pom.xml +++ b/impl/pom.xml @@ -13,7 +13,7 @@ 8.3.0 4.0.0 1.6.0 - 3.1.11 + 4.0.0 9.2.1 3.6.0 23.1.1 From 7d192fa5c9ece861bd8cd2202bd0911460f42739 Mon Sep 17 00:00:00 2001 From: Ricardo Zanini <1538000+ricardozanini@users.noreply.github.com> Date: Wed, 12 Nov 2025 12:20:37 -0500 Subject: [PATCH 10/13] feat: Replace the agent DSL with a composite of instanceId and task name (#967) * feat: Replace the agent DSL with a composite of instanceId and task name Signed-off-by: Ricardo Zanini * Change uniqueId to jsonPointer; replace tests with mock Signed-off-by: Ricardo Zanini * Extends BiFunction Signed-off-by: Ricardo Zanini --------- Signed-off-by: Ricardo Zanini --- experimental/fluent/func/pom.xml | 6 + .../fluent/func/FuncCallTaskBuilder.java | 11 ++ .../fluent/func/dsl/FuncCallStep.java | 20 +++ .../fluent/func/dsl/FuncDSL.java | 92 +++++++++-- .../fluent/func/dsl/UniqueIdBiFunction.java | 30 ++++ .../fluent/func/FuncDSLUniqueIdTest.java | 149 ++++++++++++++++++ .../api/types/func/CallJava.java | 28 +++- 7 files changed, 321 insertions(+), 15 deletions(-) create mode 100644 experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/dsl/UniqueIdBiFunction.java create mode 100644 experimental/fluent/func/src/test/java/io/serverlessworkflow/fluent/func/FuncDSLUniqueIdTest.java diff --git a/experimental/fluent/func/pom.xml b/experimental/fluent/func/pom.xml index 9b652a18..f14b4e49 100644 --- a/experimental/fluent/func/pom.xml +++ b/experimental/fluent/func/pom.xml @@ -39,6 +39,12 @@ junit-jupiter-api test + + org.mockito + mockito-core + ${version.org.mockito} + test + \ No newline at end of file diff --git a/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/FuncCallTaskBuilder.java b/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/FuncCallTaskBuilder.java index 180804d0..58115e4e 100644 --- a/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/FuncCallTaskBuilder.java +++ b/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/FuncCallTaskBuilder.java @@ -18,6 +18,7 @@ import io.serverlessworkflow.api.types.func.CallJava; import io.serverlessworkflow.api.types.func.CallTaskJava; import io.serverlessworkflow.api.types.func.JavaContextFunction; +import io.serverlessworkflow.api.types.func.JavaFilterFunction; import io.serverlessworkflow.fluent.func.spi.ConditionalTaskBuilder; import io.serverlessworkflow.fluent.func.spi.FuncTaskTransformations; import io.serverlessworkflow.fluent.spec.TaskBaseBuilder; @@ -61,6 +62,16 @@ public FuncCallTaskBuilder function( return this; } + public FuncCallTaskBuilder function(JavaFilterFunction function) { + return function(function, null); + } + + public FuncCallTaskBuilder function(JavaFilterFunction function, Class argClass) { + this.callTaskJava = new CallTaskJava(CallJava.function(function, argClass)); + super.setTask(this.callTaskJava.getCallJava()); + return this; + } + /** Accept a side-effect Consumer; engine should pass input through unchanged. */ public FuncCallTaskBuilder consumer(Consumer consumer) { this.callTaskJava = new CallTaskJava(CallJava.consumer(consumer)); diff --git a/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/dsl/FuncCallStep.java b/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/dsl/FuncCallStep.java index c784cebf..2e828e3b 100644 --- a/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/dsl/FuncCallStep.java +++ b/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/dsl/FuncCallStep.java @@ -16,6 +16,7 @@ package io.serverlessworkflow.fluent.func.dsl; import io.serverlessworkflow.api.types.func.JavaContextFunction; +import io.serverlessworkflow.api.types.func.JavaFilterFunction; import io.serverlessworkflow.fluent.func.FuncCallTaskBuilder; import io.serverlessworkflow.fluent.func.FuncTaskItemListBuilder; import java.util.function.Consumer; @@ -26,6 +27,7 @@ public final class FuncCallStep extends Step, FuncCallT private final String name; private final Function fn; private final JavaContextFunction ctxFn; + private final JavaFilterFunction filterFn; private final Class argClass; /** Function variant (unnamed). */ @@ -38,6 +40,7 @@ public final class FuncCallStep extends Step, FuncCallT this.name = name; this.fn = fn; this.ctxFn = null; + this.filterFn = null; this.argClass = argClass; } @@ -51,6 +54,21 @@ public final class FuncCallStep extends Step, FuncCallT this.name = name; this.fn = null; this.ctxFn = ctxFn; + this.filterFn = null; + this.argClass = argClass; + } + + /** JavaFilterFunction variant (unnamed). */ + FuncCallStep(JavaFilterFunction filterFn, Class argClass) { + this(null, filterFn, argClass); + } + + /** JavaFilterFunction variant (named). */ + FuncCallStep(String name, JavaFilterFunction filterFn, Class argClass) { + this.name = name; + this.fn = null; + this.ctxFn = null; + this.filterFn = filterFn; this.argClass = argClass; } @@ -60,6 +78,8 @@ protected void configure(FuncTaskItemListBuilder list, Consumer { if (ctxFn != null) { cb.function(ctxFn, argClass); + } else if (filterFn != null) { + cb.function(filterFn, argClass); } else { cb.function(fn, argClass); } 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 e6a0d917..9977c8f7 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 @@ -18,6 +18,7 @@ import io.cloudevents.CloudEventData; import io.serverlessworkflow.api.types.FlowDirectiveEnum; import io.serverlessworkflow.api.types.func.JavaContextFunction; +import io.serverlessworkflow.api.types.func.JavaFilterFunction; import io.serverlessworkflow.fluent.func.FuncCallTaskBuilder; import io.serverlessworkflow.fluent.func.FuncEmitTaskBuilder; import io.serverlessworkflow.fluent.func.FuncSwitchTaskBuilder; @@ -26,6 +27,8 @@ import io.serverlessworkflow.fluent.func.configurers.FuncTaskConfigurer; import io.serverlessworkflow.fluent.func.configurers.SwitchCaseConfigurer; import io.serverlessworkflow.fluent.func.dsl.internal.CommonFuncOps; +import io.serverlessworkflow.impl.TaskContextData; +import io.serverlessworkflow.impl.WorkflowContextData; import java.util.Collection; import java.util.List; import java.util.Map; @@ -286,7 +289,7 @@ public static FuncCallStep function(Function fn, Class cla } /** - * Build a call step for functions that need {@code WorkflowContextData} as the first parameter. + * Build a call step for functions that need {@link WorkflowContextData} as the first parameter. * The DSL wraps it as a {@link JavaContextFunction} and injects the runtime context. * *

Signature expected: {@code (ctx, payload) -> result} @@ -297,7 +300,7 @@ public static FuncCallStep function(Function fn, Class cla * @param result type * @return a call step */ - public static FuncCallStep withContext(CtxBiFunction fn, Class in) { + public static FuncCallStep withContext(JavaContextFunction fn, Class in) { return withContext(null, fn, in); } @@ -319,7 +322,7 @@ public static FuncCallStep withInstanceId( } /** - * Named variant of {@link #withContext(CtxBiFunction, Class)}. + * Named variant of {@link #withContext(JavaContextFunction, Class)}. * * @param name task name * @param fn context-aware bi-function @@ -329,9 +332,40 @@ public static FuncCallStep withInstanceId( * @return a named call step */ public static FuncCallStep withContext( - String name, CtxBiFunction fn, Class in) { - JavaContextFunction jcf = (payload, wctx) -> fn.apply(wctx, payload); - return new FuncCallStep<>(name, jcf, in); + String name, JavaContextFunction fn, Class in) { + return new FuncCallStep<>(name, fn, in); + } + + /** + * Build a call step for functions that need {@link WorkflowContextData} and {@link + * io.serverlessworkflow.impl.TaskContextData} as the first and second parameter. The DSL wraps it + * as a {@link JavaFilterFunction} and injects the runtime context. + * + *

Signature expected: {@code (wctx, tctx, payload) -> result} + * + * @param fn context-aware bi-function + * @param in payload input class + * @param input type + * @param result type + * @return a call step + */ + public static FuncCallStep withFilter(JavaFilterFunction fn, Class in) { + return withFilter(null, fn, in); + } + + /** + * Named variant of {@link #withFilter(JavaFilterFunction, Class)}. + * + * @param name task name + * @param fn context-aware bi-function + * @param in payload input class + * @param input type + * @param result type + * @return a named call step + */ + public static FuncCallStep withFilter( + String name, JavaFilterFunction fn, Class in) { + return new FuncCallStep<>(name, fn, in); } /** @@ -350,6 +384,38 @@ public static FuncCallStep withInstanceId( return new FuncCallStep<>(name, jcf, in); } + /** + * Builds a composition of the current workflow instance id and the definition of the task + * position as a JSON pointer. + */ + static String defaultUniqueId(WorkflowContextData wctx, TaskContextData tctx) { + return String.format("%s-%s", wctx.instanceData().id(), tctx.position().jsonPointer()); + } + + /** + * Build a call step for functions that expect a composition with the workflow instance id and the + * task position as the first parameter. The instance ID is extracted from the runtime context, + * the task position from the definition. + * + *

Signature expected: {@code (uniqueId, payload) -> result} + * + * @param fn unique-id-aware bi-function + * @param in payload input class + * @param input type + * @param result type + * @return a call step + */ + public static FuncCallStep withUniqueId( + String name, UniqueIdBiFunction fn, Class in) { + JavaFilterFunction jff = + (payload, wctx, tctx) -> fn.apply(defaultUniqueId(wctx, tctx), payload); + return new FuncCallStep<>(name, jff, in); + } + + public static FuncCallStep withUniqueId(UniqueIdBiFunction fn, Class in) { + return withUniqueId(null, fn, in); + } + /** * Create a fire-and-forget side-effect step (unnamed). The consumer receives the typed input. * @@ -387,12 +453,12 @@ public static ConsumeStep consume(String name, Consumer consumer, Clas * @param result type * @return a call step */ - public static FuncCallStep agent(InstanceIdBiFunction fn, Class in) { - return withInstanceId(fn, in); + public static FuncCallStep agent(UniqueIdBiFunction fn, Class in) { + return withUniqueId(fn, in); } /** - * Named agent-style sugar. See {@link #agent(InstanceIdBiFunction, Class)}. + * Named agent-style sugar. See {@link #agent(UniqueIdBiFunction, Class)}. * * @param name task name * @param fn (instanceId, payload) -> result @@ -402,8 +468,8 @@ public static FuncCallStep agent(InstanceIdBiFunction fn, Cla * @return a named call step */ public static FuncCallStep agent( - String name, InstanceIdBiFunction fn, Class in) { - return withInstanceId(name, fn, in); + String name, UniqueIdBiFunction fn, Class in) { + return withUniqueId(name, fn, in); } /** @@ -677,7 +743,7 @@ public static FuncTaskConfigurer switchWhenOrElse( * switchWhenOrElse(".approved == true", "sendEmail", FlowDirectiveEnum.END) * * - * The JQ expression is evaluated against the task input at runtime. + *

The JQ expression is evaluated against the task input at runtime. */ public static FuncTaskConfigurer switchWhenOrElse( String jqExpression, String thenTask, FlowDirectiveEnum otherwise) { @@ -698,7 +764,7 @@ public static FuncTaskConfigurer switchWhenOrElse( * switchWhenOrElse(".score >= 80", "pass", "fail") * * - * The JQ expression is evaluated against the task input at runtime. + *

The JQ expression is evaluated against the task input at runtime. */ public static FuncTaskConfigurer switchWhenOrElse( String jqExpression, String thenTask, String otherwiseTask) { diff --git a/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/dsl/UniqueIdBiFunction.java b/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/dsl/UniqueIdBiFunction.java new file mode 100644 index 00000000..229ed716 --- /dev/null +++ b/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/dsl/UniqueIdBiFunction.java @@ -0,0 +1,30 @@ +/* + * 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.fluent.func.dsl; + +import java.util.function.BiFunction; + +/** + * Functions that expect a unique ID injection in runtime, typically an idempotent generated unique + * id based on the workflow instance id and task name. + * + * @param The task payload input + * @param The task result output + */ +@FunctionalInterface +public interface UniqueIdBiFunction extends BiFunction { + R apply(String uniqueId, T object); +} diff --git a/experimental/fluent/func/src/test/java/io/serverlessworkflow/fluent/func/FuncDSLUniqueIdTest.java b/experimental/fluent/func/src/test/java/io/serverlessworkflow/fluent/func/FuncDSLUniqueIdTest.java new file mode 100644 index 00000000..fe7a9b07 --- /dev/null +++ b/experimental/fluent/func/src/test/java/io/serverlessworkflow/fluent/func/FuncDSLUniqueIdTest.java @@ -0,0 +1,149 @@ +/* + * 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.fluent.func; + +import static io.serverlessworkflow.fluent.func.dsl.FuncDSL.agent; +import static io.serverlessworkflow.fluent.func.dsl.FuncDSL.withUniqueId; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import io.serverlessworkflow.api.types.Task; +import io.serverlessworkflow.api.types.TaskItem; +import io.serverlessworkflow.api.types.Workflow; +import io.serverlessworkflow.api.types.func.CallJava; +import io.serverlessworkflow.api.types.func.JavaFilterFunction; +import io.serverlessworkflow.fluent.func.dsl.UniqueIdBiFunction; +import io.serverlessworkflow.impl.TaskContextData; +import io.serverlessworkflow.impl.WorkflowContextData; +import io.serverlessworkflow.impl.WorkflowInstanceData; +import io.serverlessworkflow.impl.WorkflowPosition; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Verifies that withUniqueId/agent wrap the user's function so that, at runtime, the first argument + * is a "unique id" composed as instanceId + "-" + jsonPointer (e.g., inst-123-/do/0/task). + */ +class FuncDSLUniqueIdTest { + + @SuppressWarnings("unchecked") + private static JavaFilterFunction extractJavaFilterFunction(CallJava callJava) { + if (callJava instanceof CallJava.CallJavaFilterFunction f) { + return (JavaFilterFunction) f.function(); + } + fail("CallTask is not a CallJavaFilterFunction; DSL contract may have changed."); + return null; // unreachable + } + + @Test + @DisplayName( + "withUniqueId(name, fn, in) composes uniqueId = instanceId-jsonPointer and passes it") + void withUniqueId_uses_json_pointer_for_unique_id() throws Exception { + AtomicReference receivedUniqueId = new AtomicReference<>(); + AtomicReference receivedPayload = new AtomicReference<>(); + + UniqueIdBiFunction fn = + (uniqueId, payload) -> { + receivedUniqueId.set(uniqueId); + receivedPayload.set(payload); + return payload.toUpperCase(); + }; + + Workflow wf = + FuncWorkflowBuilder.workflow("wf-unique-named") + .tasks(withUniqueId("notify", fn, String.class)) + .build(); + + List items = wf.getDo(); + assertEquals(1, items.size(), "one task expected"); + Task t = items.get(0).getTask(); + assertNotNull(t.getCallTask(), "CallTask expected"); + + CallJava cj = (CallJava) t.getCallTask().get(); + var jff = extractJavaFilterFunction(cj); + assertNotNull(jff, "JavaFilterFunction must be present for withUniqueId"); + + // Mockito stubs for runtime contexts + WorkflowInstanceData inst = mock(WorkflowInstanceData.class); + when(inst.id()).thenReturn("inst-123"); + + WorkflowContextData wctx = mock(WorkflowContextData.class); + when(wctx.instanceData()).thenReturn(inst); + + // Use JSON Pointer for the unique component instead of task name + final String pointer = "/do/0/task"; + WorkflowPosition pos = mock(WorkflowPosition.class); + when(pos.jsonPointer()).thenReturn(pointer); + + TaskContextData tctx = mock(TaskContextData.class); + when(tctx.position()).thenReturn(pos); + + Object result = jff.apply("hello", wctx, tctx); + + assertEquals( + "inst-123-" + pointer, receivedUniqueId.get(), "uniqueId must be instanceId-jsonPointer"); + assertEquals( + "hello", receivedPayload.get(), "payload should be forwarded to the user function"); + assertEquals("HELLO", result, "wrapped function result should be returned"); + } + + @Test + @DisplayName("agent(fn, in) composes uniqueId = instanceId-jsonPointer and passes it") + void agent_uses_json_pointer_for_unique_id() throws Exception { + AtomicReference receivedUniqueId = new AtomicReference<>(); + AtomicReference receivedPayload = new AtomicReference<>(); + + UniqueIdBiFunction fn = + (uniqueId, payload) -> { + receivedUniqueId.set(uniqueId); + receivedPayload.set(payload); + return payload + 1; + }; + + Workflow wf = FuncWorkflowBuilder.workflow("wf-agent").tasks(agent(fn, Integer.class)).build(); + + List items = wf.getDo(); + assertEquals(1, items.size(), "one task expected"); + Task t = items.get(0).getTask(); + assertNotNull(t.getCallTask(), "CallTask expected"); + + CallJava cj = (CallJava) t.getCallTask().get(); + var jff = extractJavaFilterFunction(cj); + assertNotNull(jff, "JavaFilterFunction must be present for agent/withUniqueId"); + + WorkflowInstanceData inst = mock(WorkflowInstanceData.class); + when(inst.id()).thenReturn("wf-999"); + + WorkflowContextData wctx = mock(WorkflowContextData.class); + when(wctx.instanceData()).thenReturn(inst); + + final String pointer = "/do/0/task"; + WorkflowPosition pos = mock(WorkflowPosition.class); + when(pos.jsonPointer()).thenReturn(pointer); + + TaskContextData tctx = mock(TaskContextData.class); + when(tctx.position()).thenReturn(pos); + + Object result = jff.apply(41, wctx, tctx); + + assertEquals( + "wf-999-" + pointer, receivedUniqueId.get(), "uniqueId must be instanceId-jsonPointer"); + assertEquals(41, receivedPayload.get(), "payload should be forwarded to the user function"); + assertEquals(42, result, "wrapped function result should be returned"); + } +} diff --git a/experimental/types/src/main/java/io/serverlessworkflow/api/types/func/CallJava.java b/experimental/types/src/main/java/io/serverlessworkflow/api/types/func/CallJava.java index 971e3a9c..a9699b6b 100644 --- a/experimental/types/src/main/java/io/serverlessworkflow/api/types/func/CallJava.java +++ b/experimental/types/src/main/java/io/serverlessworkflow/api/types/func/CallJava.java @@ -54,6 +54,10 @@ public static CallJava function(JavaContextFunction function, Class return new CallJavaContextFunction<>(function, Optional.ofNullable(inputClass)); } + public static CallJava function(JavaFilterFunction function, Class inputClass) { + return new CallJavaFilterFunction<>(function, Optional.ofNullable(inputClass)); + } + public static class CallJavaConsumer extends CallJava { private static final long serialVersionUID = 1L; private final Consumer consumer; @@ -95,8 +99,8 @@ public Optional> inputClass() { public static class CallJavaContextFunction extends CallJava { private static final long serialVersionUID = 1L; - private JavaContextFunction function; - private Optional> inputClass; + private final JavaContextFunction function; + private final Optional> inputClass; public CallJavaContextFunction( JavaContextFunction function, Optional> inputClass) { @@ -113,6 +117,26 @@ public Optional> inputClass() { } } + public static class CallJavaFilterFunction extends CallJava { + private static final long serialVersionUID = 1L; + private final JavaFilterFunction function; + private final Optional> inputClass; + + public CallJavaFilterFunction( + JavaFilterFunction function, Optional> inputClass) { + this.function = function; + this.inputClass = inputClass; + } + + public JavaFilterFunction function() { + return function; + } + + public Optional> inputClass() { + return inputClass; + } + } + public static class CallJavaLoopFunction extends CallJava { private static final long serialVersionUID = 1L; From b5d748dfd9a5a2411736befa34a62214a35c7966 Mon Sep 17 00:00:00 2001 From: Francisco Javier Tirado Sarti <65240126+fjtirado@users.noreply.github.com> Date: Fri, 14 Nov 2025 19:35:34 +0100 Subject: [PATCH 11/13] [Fix #976] Setting value in withXXX methods (#977) Signed-off-by: fjtirado --- .../fluent/spec/dsl/TryCatchDslTest.java | 3 --- .../generator/AllAnyOneOfSchemaRule.java | 15 +++++++++++---- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/fluent/spec/src/test/java/io/serverlessworkflow/fluent/spec/dsl/TryCatchDslTest.java b/fluent/spec/src/test/java/io/serverlessworkflow/fluent/spec/dsl/TryCatchDslTest.java index 185b65aa..bd2cb3c0 100644 --- a/fluent/spec/src/test/java/io/serverlessworkflow/fluent/spec/dsl/TryCatchDslTest.java +++ b/fluent/spec/src/test/java/io/serverlessworkflow/fluent/spec/dsl/TryCatchDslTest.java @@ -158,9 +158,6 @@ void when_try_with_multiple_tasks_and_catch_except_when_with_uri_error_filter() assertThat(catchDo).hasSize(1); var ev = catchDo.get(0).getTask().getEmitTask().getEmit().getEvent().getWith(); assertThat(ev.getType()).isEqualTo("org.acme.recover"); - - // no retry configured here - assertThat(cat.getRetry().get()).isNull(); } @Test diff --git a/generators/types/src/main/java/io/serverlessworkflow/generator/AllAnyOneOfSchemaRule.java b/generators/types/src/main/java/io/serverlessworkflow/generator/AllAnyOneOfSchemaRule.java index 35fa64e3..d94f8324 100644 --- a/generators/types/src/main/java/io/serverlessworkflow/generator/AllAnyOneOfSchemaRule.java +++ b/generators/types/src/main/java/io/serverlessworkflow/generator/AllAnyOneOfSchemaRule.java @@ -344,7 +344,7 @@ private void wrapIt( String typeName = getTypeName(node, unionType, parentSchema); JFieldVar instanceField = getInstanceField(typeName, parentSchema, definedClass, unionType, node); - JMethod method = getSetterMethod(typeName, definedClass, instanceField, node); + JMethod method = getSetterMethod(typeName, definedClass, instanceField, node, valueField); method .body() .assign( @@ -369,12 +369,18 @@ private JVar setupMethod( } private JMethod getSetterMethod( - String fieldName, JDefinedClass definedClass, JFieldVar instanceField, JsonNode node) { + String fieldName, + JDefinedClass definedClass, + JFieldVar instanceField, + JsonNode node, + Optional valueField) { String setterName = ruleFactory.getNameHelper().getSetterName(fieldName, node); JMethod fluentMethod = definedClass.method(JMod.PUBLIC, definedClass, setterName.replaceFirst("set", "with")); JBlock body = fluentMethod.body(); - body.assign(instanceField, fluentMethod.param(instanceField.type(), "value")); + JVar fluentMethodParam = fluentMethod.param(instanceField.type(), "value"); + body.assign(instanceField, fluentMethodParam); + valueField.ifPresent(v -> fluentMethod.body().assign(JExpr._this().ref(v), fluentMethodParam)); body._return(JExpr._this()); return definedClass.method(JMod.PUBLIC, definedClass.owner().VOID, setterName); } @@ -393,7 +399,8 @@ private void wrapStrings( String typeName = getTypeName(first.getNode(), first.getType(), parentSchema); JFieldVar instanceField = getInstanceField(typeName, parentSchema, definedClass, first.getType(), first.getNode()); - JMethod setterMethod = getSetterMethod(typeName, definedClass, instanceField, first.getNode()); + JMethod setterMethod = + getSetterMethod(typeName, definedClass, instanceField, first.getNode(), valueField); JVar methodParam = setupMethod(definedClass, setterMethod, valueField, instanceField); JBlock body = setterMethod.body(); if (pattern != null) { From 3c8f4ffbe2b438d6da6d46f765841d2407f9c3f8 Mon Sep 17 00:00:00 2001 From: Ricardo Zanini <1538000+ricardozanini@users.noreply.github.com> Date: Mon, 17 Nov 2025 09:18:36 -0500 Subject: [PATCH 12/13] Fix #973 - Refine support for http calls on func (#974) * Fix #973 - Refine support for http calls on func Signed-off-by: Ricardo Zanini * Added openapi support Signed-off-by: Ricardo Zanini * Add use, secrets, auth Signed-off-by: Ricardo Zanini --------- Signed-off-by: Ricardo Zanini --- .../fluent/agentic/AgentDoTaskBuilder.java | 4 +- .../agentic/AgentTaskItemListBuilder.java | 6 +- .../fluent/agentic/LoopAgentsBuilder.java | 2 +- .../fluent/agentic/dsl/AgenticDSL.java | 4 +- .../fluent/agentic/AgentDslWorkflowTest.java | 2 +- .../agentic/AgentWorkflowBuilderTest.java | 4 +- .../fluent/agentic/EmailDrafterIT.java | 4 +- .../fluent/func/FuncCallHttpTaskBuilder.java | 38 ++ .../func/FuncCallOpenAPITaskBuilder.java | 41 ++ .../fluent/func/FuncDoTaskBuilder.java | 17 +- .../fluent/func/FuncTaskItemListBuilder.java | 43 +- .../configurers/FuncCallHttpConfigurer.java | 22 + .../FuncCallOpenAPIConfigurer.java | 22 + .../fluent/func/dsl/ConsumeStep.java | 4 +- .../fluent/func/dsl/FuncCallHttpSpec.java | 34 ++ .../fluent/func/dsl/FuncCallOpenAPISpec.java | 90 +++ .../fluent/func/dsl/FuncCallStep.java | 4 +- .../fluent/func/dsl/FuncDSL.java | 380 +++++++++++++ .../fluent/func/spi/CallFnFluent.java | 6 +- .../fluent/func/spi/FuncDoFluent.java | 10 +- .../fluent/func/FuncDSLConsumeTest.java | 2 +- .../fluent/func/FuncDSLTest.java | 231 +++++++- .../workflow/impl/FluentDSLCallTest.java | 4 +- .../fluent/spec/BaseWorkflowBuilder.java | 18 + .../fluent/spec/CallHTTPTaskBuilder.java | 202 ------- .../fluent/spec/CallHttpTaskBuilder.java | 34 ++ .../fluent/spec/CallOpenAPITaskBuilder.java | 36 ++ .../fluent/spec/DoTaskBuilder.java | 10 +- ...renceableAuthenticationPolicyBuilder.java} | 30 +- .../fluent/spec/TaskItemListBuilder.java | 31 +- .../spec/UseAuthenticationsBuilder.java | 7 +- .../fluent/spec/UseBuilder.java | 8 +- .../configurers/AuthenticationConfigurer.java | 5 +- ...onfigurer.java => CallHttpConfigurer.java} | 4 +- .../configurers/CallOpenAPIConfigurer.java | 22 + .../spec/configurers/UseConfigurer.java | 22 + ...allHTTPSpec.java => BaseCallHttpSpec.java} | 72 +-- .../fluent/spec/dsl/CallHttpSpec.java | 33 ++ .../fluent/spec/dsl/CallOpenAPISpec.java | 89 +++ .../fluent/spec/dsl/DSL.java | 522 +++++++++++++++++- .../fluent/spec/dsl/UseSpec.java | 47 ++ ...allHTTPFluent.java => CallHttpFluent.java} | 8 +- .../fluent/spec/spi/CallHttpTaskFluent.java | 205 +++++++ .../fluent/spec/spi/CallOpenAPIFluent.java | 29 + .../spec/spi/CallOpenAPITaskFluent.java | 143 +++++ .../fluent/spec/spi/DoFluent.java | 8 +- .../fluent/spec/spi/EndpointUtil.java | 66 +++ .../fluent/spec/WorkflowBuilderTest.java | 22 +- .../fluent/spec/dsl/CallHttpAuthDslTest.java | 57 +- .../fluent/spec/dsl/CallOpenApiDslTest.java | 95 ++++ .../fluent/spec/dsl/DSLTest.java | 37 +- 51 files changed, 2479 insertions(+), 357 deletions(-) create mode 100644 experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/FuncCallHttpTaskBuilder.java create mode 100644 experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/FuncCallOpenAPITaskBuilder.java create mode 100644 experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/configurers/FuncCallHttpConfigurer.java create mode 100644 experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/configurers/FuncCallOpenAPIConfigurer.java create mode 100644 experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/dsl/FuncCallHttpSpec.java create mode 100644 experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/dsl/FuncCallOpenAPISpec.java delete mode 100644 fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/CallHTTPTaskBuilder.java create mode 100644 fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/CallHttpTaskBuilder.java create mode 100644 fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/CallOpenAPITaskBuilder.java rename fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/{AuthenticationPolicyUnionBuilder.java => ReferenceableAuthenticationPolicyBuilder.java} (71%) rename fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/configurers/{CallHTTPConfigurer.java => CallHttpConfigurer.java} (85%) create mode 100644 fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/configurers/CallOpenAPIConfigurer.java create mode 100644 fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/configurers/UseConfigurer.java rename fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/dsl/{CallHTTPSpec.java => BaseCallHttpSpec.java} (55%) create mode 100644 fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/dsl/CallHttpSpec.java create mode 100644 fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/dsl/CallOpenAPISpec.java create mode 100644 fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/dsl/UseSpec.java rename fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/spi/{CallHTTPFluent.java => CallHttpFluent.java} (76%) create mode 100644 fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/spi/CallHttpTaskFluent.java create mode 100644 fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/spi/CallOpenAPIFluent.java create mode 100644 fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/spi/CallOpenAPITaskFluent.java create mode 100644 fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/spi/EndpointUtil.java create mode 100644 fluent/spec/src/test/java/io/serverlessworkflow/fluent/spec/dsl/CallOpenApiDslTest.java diff --git a/experimental/fluent/agentic/src/main/java/io/serverlessworkflow/fluent/agentic/AgentDoTaskBuilder.java b/experimental/fluent/agentic/src/main/java/io/serverlessworkflow/fluent/agentic/AgentDoTaskBuilder.java index 5d7861d8..4d7c42a2 100644 --- a/experimental/fluent/agentic/src/main/java/io/serverlessworkflow/fluent/agentic/AgentDoTaskBuilder.java +++ b/experimental/fluent/agentic/src/main/java/io/serverlessworkflow/fluent/agentic/AgentDoTaskBuilder.java @@ -70,8 +70,8 @@ public AgentDoTaskBuilder parallel(String name, Object... agents) { } @Override - public AgentDoTaskBuilder callFn(String name, Consumer cfg) { - this.listBuilder().callFn(name, cfg); + public AgentDoTaskBuilder function(String name, Consumer cfg) { + this.listBuilder().function(name, cfg); return self(); } diff --git a/experimental/fluent/agentic/src/main/java/io/serverlessworkflow/fluent/agentic/AgentTaskItemListBuilder.java b/experimental/fluent/agentic/src/main/java/io/serverlessworkflow/fluent/agentic/AgentTaskItemListBuilder.java index 7ce0ea8e..d4820312 100644 --- a/experimental/fluent/agentic/src/main/java/io/serverlessworkflow/fluent/agentic/AgentTaskItemListBuilder.java +++ b/experimental/fluent/agentic/src/main/java/io/serverlessworkflow/fluent/agentic/AgentTaskItemListBuilder.java @@ -56,7 +56,7 @@ public AgentTaskItemListBuilder agent(String name, Object agent) { AgentAdapters.toExecutors(agent) .forEach( exec -> - this.delegate.callFn( + this.delegate.function( name, fn -> fn.function(AgentAdapters.toFunction(exec), DefaultAgenticScope.class))); return self(); @@ -103,8 +103,8 @@ public AgentTaskItemListBuilder parallel(String name, Object... agents) { } @Override - public AgentTaskItemListBuilder callFn(String name, Consumer cfg) { - this.delegate.callFn(name, cfg); + public AgentTaskItemListBuilder function(String name, Consumer cfg) { + this.delegate.function(name, cfg); return self(); } diff --git a/experimental/fluent/agentic/src/main/java/io/serverlessworkflow/fluent/agentic/LoopAgentsBuilder.java b/experimental/fluent/agentic/src/main/java/io/serverlessworkflow/fluent/agentic/LoopAgentsBuilder.java index 9cea18de..dd1d6c00 100644 --- a/experimental/fluent/agentic/src/main/java/io/serverlessworkflow/fluent/agentic/LoopAgentsBuilder.java +++ b/experimental/fluent/agentic/src/main/java/io/serverlessworkflow/fluent/agentic/LoopAgentsBuilder.java @@ -49,7 +49,7 @@ public LoopAgentsBuilder subAgents(String baseName, Object... agents) { forEachIndexed( execs, (exec, idx) -> - funcDelegate.callFn( + funcDelegate.function( baseName + "-" + idx, fn -> fn.function(AgentAdapters.toFunction(exec)))); return this; } diff --git a/experimental/fluent/agentic/src/main/java/io/serverlessworkflow/fluent/agentic/dsl/AgenticDSL.java b/experimental/fluent/agentic/src/main/java/io/serverlessworkflow/fluent/agentic/dsl/AgenticDSL.java index df377403..fbaa7e00 100644 --- a/experimental/fluent/agentic/src/main/java/io/serverlessworkflow/fluent/agentic/dsl/AgenticDSL.java +++ b/experimental/fluent/agentic/src/main/java/io/serverlessworkflow/fluent/agentic/dsl/AgenticDSL.java @@ -143,12 +143,12 @@ public static Consumer doTasks(AgentTaskConfigurer... steps) } public static AgentTaskConfigurer function(Function function, Class argClass) { - return list -> list.callFn(fn(function, argClass)); + return list -> list.function(fn(function, argClass)); } public static AgentTaskConfigurer function(Function function) { Class clazz = ReflectionUtils.inferInputType(function); - return list -> list.callFn(fn(function, clazz)); + return list -> list.function(fn(function, clazz)); } public static AgentTaskConfigurer agent(Object agent) { diff --git a/experimental/fluent/agentic/src/test/java/io/serverlessworkflow/fluent/agentic/AgentDslWorkflowTest.java b/experimental/fluent/agentic/src/test/java/io/serverlessworkflow/fluent/agentic/AgentDslWorkflowTest.java index c8be5f7a..2b6947fd 100644 --- a/experimental/fluent/agentic/src/test/java/io/serverlessworkflow/fluent/agentic/AgentDslWorkflowTest.java +++ b/experimental/fluent/agentic/src/test/java/io/serverlessworkflow/fluent/agentic/AgentDslWorkflowTest.java @@ -68,7 +68,7 @@ private void assertSequentialAgents(Workflow wf) { void dslCallFnBare() { Workflow wf = workflow("beanCall") - .tasks(tasks -> tasks.callFn("plainCall", fn -> fn.function(ctx -> "pong"))) + .tasks(tasks -> tasks.function("plainCall", fn -> fn.function(ctx -> "pong"))) .build(); List items = wf.getDo(); diff --git a/experimental/fluent/agentic/src/test/java/io/serverlessworkflow/fluent/agentic/AgentWorkflowBuilderTest.java b/experimental/fluent/agentic/src/test/java/io/serverlessworkflow/fluent/agentic/AgentWorkflowBuilderTest.java index b659f570..621007de 100644 --- a/experimental/fluent/agentic/src/test/java/io/serverlessworkflow/fluent/agentic/AgentWorkflowBuilderTest.java +++ b/experimental/fluent/agentic/src/test/java/io/serverlessworkflow/fluent/agentic/AgentWorkflowBuilderTest.java @@ -177,7 +177,7 @@ void parallelAgents() { void testWorkflowCallFnBare() { Workflow wf = AgentWorkflowBuilder.workflow() - .tasks(d -> d.callFn("myCall", fn -> fn.function(ctx -> "hello"))) + .tasks(d -> d.function("myCall", fn -> fn.function(ctx -> "hello"))) .build(); assertThat(wf.getDo()).hasSize(1); @@ -193,7 +193,7 @@ void testWorkflowCallFnWithPredicate() { Workflow wf = AgentWorkflowBuilder.workflow() - .tasks(d -> d.callFn("guarded", fn -> fn.function(ctx -> "x").when(guard))) + .tasks(d -> d.function("guarded", fn -> fn.function(ctx -> "x").when(guard))) .build(); TaskItem ti = wf.getDo().get(0); diff --git a/experimental/fluent/agentic/src/test/java/io/serverlessworkflow/fluent/agentic/EmailDrafterIT.java b/experimental/fluent/agentic/src/test/java/io/serverlessworkflow/fluent/agentic/EmailDrafterIT.java index 4925ed64..e0eb6e54 100644 --- a/experimental/fluent/agentic/src/test/java/io/serverlessworkflow/fluent/agentic/EmailDrafterIT.java +++ b/experimental/fluent/agentic/src/test/java/io/serverlessworkflow/fluent/agentic/EmailDrafterIT.java @@ -62,8 +62,8 @@ void email_drafter_agent() { tasks -> tasks .agent("agentEmailDrafter", emailDrafter) - .callFn("parseDraft", fn(EmailDrafts::parse, String.class)) - .callFn("policyCheck", fn(EmailPolicies::policyCheck, EmailDraft.class)) + .function("parseDraft", fn(EmailDrafts::parse, String.class)) + .function("policyCheck", fn(EmailPolicies::policyCheck, EmailDraft.class)) .switchCase( "needsHumanReview?", cases( diff --git a/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/FuncCallHttpTaskBuilder.java b/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/FuncCallHttpTaskBuilder.java new file mode 100644 index 00000000..bf06e589 --- /dev/null +++ b/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/FuncCallHttpTaskBuilder.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.fluent.func; + +import io.serverlessworkflow.api.types.CallHTTP; +import io.serverlessworkflow.api.types.HTTPArguments; +import io.serverlessworkflow.fluent.func.spi.ConditionalTaskBuilder; +import io.serverlessworkflow.fluent.func.spi.FuncTaskTransformations; +import io.serverlessworkflow.fluent.spec.TaskBaseBuilder; +import io.serverlessworkflow.fluent.spec.spi.CallHttpTaskFluent; + +public class FuncCallHttpTaskBuilder extends TaskBaseBuilder + implements CallHttpTaskFluent, + FuncTaskTransformations, + ConditionalTaskBuilder { + + FuncCallHttpTaskBuilder() { + super.setTask(new CallHTTP().withWith(new HTTPArguments())); + } + + @Override + public FuncCallHttpTaskBuilder self() { + return this; + } +} diff --git a/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/FuncCallOpenAPITaskBuilder.java b/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/FuncCallOpenAPITaskBuilder.java new file mode 100644 index 00000000..8c86af9b --- /dev/null +++ b/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/FuncCallOpenAPITaskBuilder.java @@ -0,0 +1,41 @@ +/* + * 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.fluent.func; + +import io.serverlessworkflow.api.types.CallOpenAPI; +import io.serverlessworkflow.api.types.OpenAPIArguments; +import io.serverlessworkflow.api.types.WithOpenAPIParameters; +import io.serverlessworkflow.fluent.func.spi.ConditionalTaskBuilder; +import io.serverlessworkflow.fluent.func.spi.FuncTaskTransformations; +import io.serverlessworkflow.fluent.spec.TaskBaseBuilder; +import io.serverlessworkflow.fluent.spec.spi.CallOpenAPITaskFluent; + +public class FuncCallOpenAPITaskBuilder extends TaskBaseBuilder + implements CallOpenAPITaskFluent, + FuncTaskTransformations, + ConditionalTaskBuilder { + + FuncCallOpenAPITaskBuilder() { + final CallOpenAPI callOpenAPI = new CallOpenAPI(); + callOpenAPI.setWith(new OpenAPIArguments().withParameters(new WithOpenAPIParameters())); + super.setTask(callOpenAPI); + } + + @Override + public FuncCallOpenAPITaskBuilder self() { + return this; + } +} 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 2b03dedf..b2bdf5fa 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 @@ -73,8 +73,8 @@ public FuncDoTaskBuilder switchCase( } @Override - public FuncDoTaskBuilder callFn(String name, Consumer cfg) { - this.listBuilder().callFn(name, cfg); + public FuncDoTaskBuilder function(String name, Consumer cfg) { + this.listBuilder().function(name, cfg); return this; } @@ -83,4 +83,17 @@ public FuncDoTaskBuilder fork(String name, Consumer itemsCo this.listBuilder().fork(name, itemsConfigurer); return this; } + + @Override + public FuncDoTaskBuilder http(String name, Consumer itemsConfigurer) { + this.listBuilder().http(name, itemsConfigurer); + return this; + } + + @Override + public FuncDoTaskBuilder openapi( + String name, Consumer itemsConfigurer) { + this.listBuilder().openapi(name, itemsConfigurer); + return this; + } } 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 e31faf60..cbf954d1 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 @@ -15,6 +15,9 @@ */ package io.serverlessworkflow.fluent.func; +import io.serverlessworkflow.api.types.CallHTTP; +import io.serverlessworkflow.api.types.CallOpenAPI; +import io.serverlessworkflow.api.types.CallTask; import io.serverlessworkflow.api.types.Task; import io.serverlessworkflow.api.types.TaskItem; import io.serverlessworkflow.fluent.func.spi.FuncDoFluent; @@ -45,7 +48,7 @@ protected FuncTaskItemListBuilder newItemListBuilder() { } @Override - public FuncTaskItemListBuilder callFn(String name, Consumer consumer) { + public FuncTaskItemListBuilder function(String name, Consumer consumer) { name = this.defaultNameAndRequireConfig(name, consumer); final FuncCallTaskBuilder callTaskJavaBuilder = new FuncCallTaskBuilder(); consumer.accept(callTaskJavaBuilder); @@ -53,8 +56,8 @@ public FuncTaskItemListBuilder callFn(String name, Consumer } @Override - public FuncTaskItemListBuilder callFn(Consumer consumer) { - return this.callFn(UUID.randomUUID().toString(), consumer); + public FuncTaskItemListBuilder function(Consumer consumer) { + return this.function(UUID.randomUUID().toString(), consumer); } @Override @@ -116,4 +119,38 @@ public FuncTaskItemListBuilder fork(String name, Consumer i return this.addTaskItem( new TaskItem(name, new Task().withForkTask(forkTaskJavaBuilder.build()))); } + + @Override + public FuncTaskItemListBuilder http( + String name, Consumer itemsConfigurer) { + name = this.defaultNameAndRequireConfig(name, itemsConfigurer); + + final FuncCallHttpTaskBuilder httpTaskJavaBuilder = new FuncCallHttpTaskBuilder(); + itemsConfigurer.accept(httpTaskJavaBuilder); + + final CallHTTP callHTTP = httpTaskJavaBuilder.build(); + final CallTask callTask = new CallTask(); + callTask.setCallHTTP(callHTTP); + final Task task = new Task(); + task.setCallTask(callTask); + + return this.addTaskItem(new TaskItem(name, task)); + } + + @Override + public FuncTaskItemListBuilder openapi( + String name, Consumer itemsConfigurer) { + name = this.defaultNameAndRequireConfig(name, itemsConfigurer); + + final FuncCallOpenAPITaskBuilder openAPITaskBuilder = new FuncCallOpenAPITaskBuilder(); + itemsConfigurer.accept(openAPITaskBuilder); + + final CallOpenAPI callOpenAPI = openAPITaskBuilder.build(); + final CallTask callTask = new CallTask(); + callTask.setCallOpenAPI(callOpenAPI); + final Task task = new Task(); + task.setCallTask(callTask); + + return this.addTaskItem(new TaskItem(name, task)); + } } diff --git a/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/configurers/FuncCallHttpConfigurer.java b/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/configurers/FuncCallHttpConfigurer.java new file mode 100644 index 00000000..d852f82c --- /dev/null +++ b/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/configurers/FuncCallHttpConfigurer.java @@ -0,0 +1,22 @@ +/* + * 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.fluent.func.configurers; + +import io.serverlessworkflow.fluent.func.FuncCallHttpTaskBuilder; +import java.util.function.Consumer; + +@FunctionalInterface +public interface FuncCallHttpConfigurer extends Consumer {} diff --git a/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/configurers/FuncCallOpenAPIConfigurer.java b/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/configurers/FuncCallOpenAPIConfigurer.java new file mode 100644 index 00000000..210b5792 --- /dev/null +++ b/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/configurers/FuncCallOpenAPIConfigurer.java @@ -0,0 +1,22 @@ +/* + * 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.fluent.func.configurers; + +import io.serverlessworkflow.fluent.func.FuncCallOpenAPITaskBuilder; +import java.util.function.Consumer; + +@FunctionalInterface +public interface FuncCallOpenAPIConfigurer extends Consumer {} diff --git a/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/dsl/ConsumeStep.java b/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/dsl/ConsumeStep.java index 34339f0e..b9a99ac9 100644 --- a/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/dsl/ConsumeStep.java +++ b/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/dsl/ConsumeStep.java @@ -38,7 +38,7 @@ public final class ConsumeStep extends Step, FuncCallTaskBuild protected void configure( FuncTaskItemListBuilder list, java.util.function.Consumer post) { if (name == null) { - list.callFn( + list.function( cb -> { // prefer the typed consumer if your builder supports it; otherwise fallback: if (argClass != null) cb.consumer(consumer, argClass); @@ -46,7 +46,7 @@ protected void configure( post.accept(cb); }); } else { - list.callFn( + list.function( name, cb -> { if (argClass != null) cb.consumer(consumer, argClass); diff --git a/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/dsl/FuncCallHttpSpec.java b/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/dsl/FuncCallHttpSpec.java new file mode 100644 index 00000000..58259b80 --- /dev/null +++ b/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/dsl/FuncCallHttpSpec.java @@ -0,0 +1,34 @@ +/* + * 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.fluent.func.dsl; + +import io.serverlessworkflow.fluent.func.FuncCallHttpTaskBuilder; +import io.serverlessworkflow.fluent.func.configurers.FuncCallHttpConfigurer; +import io.serverlessworkflow.fluent.spec.dsl.BaseCallHttpSpec; + +public class FuncCallHttpSpec extends BaseCallHttpSpec + implements FuncCallHttpConfigurer { + + @Override + protected FuncCallHttpSpec self() { + return this; + } + + @Override + public void accept(FuncCallHttpTaskBuilder funcCallHttpTaskBuilder) { + super.accept(funcCallHttpTaskBuilder); + } +} diff --git a/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/dsl/FuncCallOpenAPISpec.java b/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/dsl/FuncCallOpenAPISpec.java new file mode 100644 index 00000000..5915920b --- /dev/null +++ b/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/dsl/FuncCallOpenAPISpec.java @@ -0,0 +1,90 @@ +/* + * 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.fluent.func.dsl; + +import io.serverlessworkflow.api.types.OpenAPIArguments; +import io.serverlessworkflow.fluent.func.FuncCallOpenAPITaskBuilder; +import io.serverlessworkflow.fluent.func.configurers.FuncCallOpenAPIConfigurer; +import io.serverlessworkflow.fluent.spec.configurers.AuthenticationConfigurer; +import io.serverlessworkflow.fluent.spec.spi.CallOpenAPITaskFluent; +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +public class FuncCallOpenAPISpec implements FuncCallOpenAPIConfigurer { + + private final List>> steps = new ArrayList<>(); + + public FuncCallOpenAPISpec document(String uri) { + steps.add(b -> b.document(uri)); + return this; + } + + public FuncCallOpenAPISpec document( + String uri, AuthenticationConfigurer authenticationConfigurer) { + steps.add(b -> b.document(uri, authenticationConfigurer)); + return this; + } + + public FuncCallOpenAPISpec document(URI uri) { + steps.add(b -> b.document(uri)); + return this; + } + + public FuncCallOpenAPISpec document(URI uri, AuthenticationConfigurer authenticationConfigurer) { + steps.add(b -> b.document(uri, authenticationConfigurer)); + return this; + } + + public FuncCallOpenAPISpec operation(String operationId) { + steps.add(b -> b.operation(operationId)); + return this; + } + + public FuncCallOpenAPISpec parameters(Map params) { + steps.add(b -> b.parameters(params)); + return this; + } + + public FuncCallOpenAPISpec parameter(String name, String value) { + steps.add(b -> b.parameter(name, value)); + return this; + } + + public FuncCallOpenAPISpec redirect(boolean redirect) { + steps.add(b -> b.redirect(redirect)); + return this; + } + + public FuncCallOpenAPISpec authentication(AuthenticationConfigurer authenticationConfigurer) { + steps.add(b -> b.authentication(authenticationConfigurer)); + return this; + } + + public FuncCallOpenAPISpec output(OpenAPIArguments.WithOpenAPIOutput output) { + steps.add(b -> b.output(output)); + return this; + } + + @Override + public void accept(FuncCallOpenAPITaskBuilder builder) { + for (var s : steps) { + s.accept(builder); + } + } +} diff --git a/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/dsl/FuncCallStep.java b/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/dsl/FuncCallStep.java index 2e828e3b..8575efe1 100644 --- a/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/dsl/FuncCallStep.java +++ b/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/dsl/FuncCallStep.java @@ -87,9 +87,9 @@ protected void configure(FuncTaskItemListBuilder list, Consumer FuncTaskConfigurer switchWhen( return list -> list.switchCase(cases(caseOf(pred, predClass).then(thenTask))); } + /** + * JQ-based condition: if the JQ expression evaluates truthy → jump to {@code thenTask}. + * + *

+   *   switchWhen(".approved == true", "approveOrder")
+   * 
+ * + *

The JQ expression is evaluated against the task input at runtime. When the predicate is + * false, the workflow follows the default flow directive for the switch task (as defined by the + * underlying implementation / spec). + * + * @param jqExpression JQ expression evaluated against the current task input + * @param thenTask task name to jump to when the expression evaluates truthy + * @return list configurer + */ public static FuncTaskConfigurer switchWhen(String jqExpression, String thenTask) { return list -> list.switchCase(sw -> sw.on(c -> c.when(jqExpression).then(thenTask))); } @@ -837,4 +855,366 @@ public static FuncTaskConfigurer set(String expr) { public static FuncTaskConfigurer set(Map map) { return list -> list.set(s -> s.expr(map)); } + + /** + * Low-level HTTP call entrypoint using a {@link FuncCallHttpConfigurer}. + * + *

This overload creates an unnamed HTTP task. + * + * @param configurer the configurer that mutates the underlying HTTP call builder + * @return a {@link FuncTaskConfigurer} that adds an HTTP task to the tasks list + */ + public static FuncTaskConfigurer call(FuncCallHttpConfigurer configurer) { + return call(null, configurer); + } + + /** + * Low-level HTTP call entrypoint using a {@link FuncCallHttpConfigurer}. + * + *

This overload allows assigning an explicit task name. + * + * @param name task name, or {@code null} for an anonymous task + * @param configurer the configurer that mutates the underlying HTTP call builder + * @return a {@link FuncTaskConfigurer} that adds an HTTP task to the tasks list + */ + public static FuncTaskConfigurer call(String name, FuncCallHttpConfigurer configurer) { + Objects.requireNonNull(configurer, "configurer"); + return list -> list.http(name, configurer); + } + + /** + * HTTP call using a fluent {@link FuncCallHttpSpec}. + * + *

This overload creates an unnamed HTTP task. + * + *

{@code
+   * tasks(
+   *   FuncDSL.call(
+   *     FuncDSL.http()
+   *       .GET()
+   *       .endpoint("http://service/api")
+   *   )
+   * );
+   * }
+ * + * @param spec fluent HTTP spec built via {@link #http()} + * @return a {@link FuncTaskConfigurer} that adds an HTTP task + */ + public static FuncTaskConfigurer call(FuncCallHttpSpec spec) { + return call(null, spec); + } + + /** + * HTTP call using a fluent {@link FuncCallHttpSpec} with explicit task name. + * + *
{@code
+   * tasks(
+   *   FuncDSL.call("fetchUsers",
+   *     FuncDSL.http()
+   *       .GET()
+   *       .endpoint("http://service/users")
+   *   )
+   * );
+   * }
+ * + * @param name task name, or {@code null} for an anonymous task + * @param spec fluent HTTP spec built via {@link #http()} + * @return a {@link FuncTaskConfigurer} that adds an HTTP task + */ + public static FuncTaskConfigurer call(String name, FuncCallHttpSpec spec) { + Objects.requireNonNull(spec, "spec"); + return call(name, spec::accept); + } + + /** + * OpenAPI call using a fluent {@link FuncCallOpenAPISpec}. + * + *

This overload creates an unnamed OpenAPI call task. + * + *

{@code
+   * FuncWorkflowBuilder.workflow("openapi-call")
+   *   .tasks(
+   *     FuncDSL.call(
+   *       FuncDSL.openapi()
+   *         .document("https://petstore.swagger.io/v2/swagger.json", DSL.auth("openapi-auth"))
+   *         .operation("getPetById")
+   *     )
+   *   )
+   *   .build();
+   * }
+ * + * @param spec fluent OpenAPI spec built via {@link #openapi()} + * @return a {@link FuncTaskConfigurer} that adds an OpenAPI call task to the workflow + */ + public static FuncTaskConfigurer call(FuncCallOpenAPISpec spec) { + return call(null, spec); + } + + /** + * OpenAPI call using a fluent {@link FuncCallOpenAPISpec} with an explicit task name. + * + *

Example: + * + *

{@code
+   * FuncWorkflowBuilder.workflow("openapi-call-named")
+   *   .tasks(
+   *     FuncDSL.call(
+   *       "fetchPet",
+   *       FuncDSL.openapi()
+   *         .document("https://petstore.swagger.io/v2/swagger.json", DSL.auth("openapi-auth"))
+   *         .operation("getPetById")
+   *         .parameter("id", 123)
+   *     )
+   *   )
+   *   .build();
+   * }
+ * + * @param name task name, or {@code null} for an anonymous task + * @param spec fluent OpenAPI spec built via {@link #openapi()} + * @return a {@link FuncTaskConfigurer} that adds a named OpenAPI call task + */ + public static FuncTaskConfigurer call(String name, FuncCallOpenAPISpec spec) { + Objects.requireNonNull(spec, "spec"); + return list -> list.openapi(name, spec); + } + + /** + * Create a new OpenAPI specification to be used with {@link #call(FuncCallOpenAPISpec)}. + * + *

Typical usage: + * + *

{@code
+   * FuncDSL.call(
+   *   FuncDSL.openapi()
+   *     .document("https://petstore.swagger.io/v2/swagger.json", DSL.auth("openapi-auth"))
+   *     .operation("getPetById")
+   *     .parameter("id", 123)
+   * );
+   * }
+ * + *

The returned spec is a fluent builder that records operations (document, operation, + * parameters, authentication, etc.) and applies them to the underlying OpenAPI call task at build + * time. + * + * @return a new {@link FuncCallOpenAPISpec} + */ + public static FuncCallOpenAPISpec openapi() { + return new FuncCallOpenAPISpec(); + } + + /** + * Create a new, empty HTTP specification to be used with {@link #call(FuncCallHttpSpec)}. + * + *

Typical usage: + * + *

{@code
+   * FuncDSL.call(
+   *   FuncDSL.http()
+   *     .GET()
+   *     .endpoint("http://service/api")
+   *     .acceptJSON()
+   * );
+   * }
+ * + * @return a new {@link FuncCallHttpSpec} + */ + public static FuncCallHttpSpec http() { + return new FuncCallHttpSpec(); + } + + /** + * Create a new HTTP specification preconfigured with an endpoint expression and authentication. + * + *
{@code
+   * FuncDSL.call(
+   *   FuncDSL.http("http://service/api", auth -> auth.use("my-auth"))
+   *     .GET()
+   * );
+   * }
+ * + * @param urlExpr expression or literal string for the endpoint URL + * @param auth authentication configurer (e.g. {@code auth -> auth.use("my-auth")}) + * @return a {@link FuncCallHttpSpec} preconfigured with endpoint + auth + */ + public static FuncCallHttpSpec http(String urlExpr, AuthenticationConfigurer auth) { + return new FuncCallHttpSpec().endpoint(urlExpr, auth); + } + + /** + * Create a new HTTP specification preconfigured with a {@link URI} and authentication. + * + * @param url concrete URI to call + * @param auth authentication configurer + * @return a {@link FuncCallHttpSpec} preconfigured with URI + auth + */ + public static FuncCallHttpSpec http(URI url, AuthenticationConfigurer auth) { + return new FuncCallHttpSpec().uri(url, auth); + } + + /** + * Convenience for adding an unnamed {@code GET} HTTP task using a string endpoint. + * + *
{@code
+   * tasks(
+   *   FuncDSL.get("http://service/health")
+   * );
+   * }
+ * + * @param endpoint literal or expression for the endpoint URL + * @return a {@link FuncTaskConfigurer} adding a {@code GET} HTTP task + */ + public static FuncTaskConfigurer get(String endpoint) { + return get(null, endpoint); + } + + /** + * Convenience for adding a named {@code GET} HTTP task using a string endpoint. + * + *
{@code
+   * tasks(
+   *   FuncDSL.get("checkHealth", "http://service/health")
+   * );
+   * }
+ * + * @param name task name + * @param endpoint literal or expression for the endpoint URL + * @return a {@link FuncTaskConfigurer} adding a {@code GET} HTTP task + */ + public static FuncTaskConfigurer get(String name, String endpoint) { + return call(name, http().GET().endpoint(endpoint)); + } + + /** + * Convenience for adding an unnamed authenticated {@code GET} HTTP task using a string endpoint. + * + *
{@code
+   * tasks(
+   *   FuncDSL.get("http://service/api/users", auth -> auth.use("user-service-auth"))
+   * );
+   * }
+ * + * @param endpoint literal or expression for the endpoint URL + * @param auth authentication configurer + * @return a {@link FuncTaskConfigurer} adding a {@code GET} HTTP task + */ + public static FuncTaskConfigurer get(String endpoint, AuthenticationConfigurer auth) { + return get(null, endpoint, auth); + } + + /** + * Convenience for adding a named authenticated {@code GET} HTTP task using a string endpoint. + * + * @param name task name + * @param endpoint literal or expression for the endpoint URL + * @param auth authentication configurer + * @return a {@link FuncTaskConfigurer} adding a {@code GET} HTTP task + */ + public static FuncTaskConfigurer get( + String name, String endpoint, AuthenticationConfigurer auth) { + return call(name, http().GET().endpoint(endpoint, auth)); + } + + /** + * Convenience for adding an unnamed {@code GET} HTTP task using a {@link URI}. + * + * @param endpoint concrete URI to call + * @return a {@link FuncTaskConfigurer} adding a {@code GET} HTTP task + */ + public static FuncTaskConfigurer get(URI endpoint) { + return get(null, endpoint); + } + + /** + * Convenience for adding a named {@code GET} HTTP task using a {@link URI}. + * + * @param name task name + * @param endpoint concrete URI to call + * @return a {@link FuncTaskConfigurer} adding a {@code GET} HTTP task + */ + public static FuncTaskConfigurer get(String name, URI endpoint) { + return call(name, http().GET().uri(endpoint)); + } + + /** + * Convenience for adding an unnamed authenticated {@code GET} HTTP task using a {@link URI}. + * + * @param endpoint concrete URI to call + * @param auth authentication configurer + * @return a {@link FuncTaskConfigurer} adding a {@code GET} HTTP task + */ + public static FuncTaskConfigurer get(URI endpoint, AuthenticationConfigurer auth) { + return get(null, endpoint, auth); + } + + /** + * Convenience for adding a named authenticated {@code GET} HTTP task using a {@link URI}. + * + * @param name task name + * @param endpoint concrete URI to call + * @param auth authentication configurer + * @return a {@link FuncTaskConfigurer} adding a {@code GET} HTTP task + */ + public static FuncTaskConfigurer get(String name, URI endpoint, AuthenticationConfigurer auth) { + return call(name, http().GET().uri(endpoint, auth)); + } + + /** + * Convenience for adding an unnamed {@code POST} HTTP task with a body and string endpoint. + * + *
{@code
+   * tasks(
+   *   FuncDSL.post(
+   *     Map.of("name", "Ricardo"),
+   *     "http://service/api/users"
+   *   )
+   * );
+   * }
+ * + * @param body HTTP request body (literal value or expression-compatible object) + * @param endpointExpr literal or expression for the endpoint URL + * @return a {@link FuncTaskConfigurer} adding a {@code POST} HTTP task + */ + public static FuncTaskConfigurer post(Object body, String endpointExpr) { + return post(null, body, endpointExpr); + } + + /** + * Convenience for adding a named {@code POST} HTTP task with a body and string endpoint. + * + * @param name task name + * @param body HTTP request body (literal value or expression-compatible object) + * @param endpoint literal or expression for the endpoint URL + * @return a {@link FuncTaskConfigurer} adding a {@code POST} HTTP task + */ + public static FuncTaskConfigurer post(String name, Object body, String endpoint) { + return call(name, http().POST().endpoint(endpoint).body(body)); + } + + /** + * Convenience for adding an unnamed authenticated {@code POST} HTTP task with body and endpoint. + * + * @param body HTTP request body + * @param endpoint literal or expression for the endpoint URL + * @param auth authentication configurer + * @return a {@link FuncTaskConfigurer} adding an authenticated {@code POST} HTTP task + */ + public static FuncTaskConfigurer post( + Object body, String endpoint, AuthenticationConfigurer auth) { + return post(null, body, endpoint, auth); + } + + /** + * Convenience for adding a named authenticated {@code POST} HTTP task with body and endpoint. + * + * @param name task name + * @param body HTTP request body + * @param endpoint literal or expression for the endpoint URL + * @param auth authentication configurer + * @return a {@link FuncTaskConfigurer} adding an authenticated {@code POST} HTTP task + */ + public static FuncTaskConfigurer post( + String name, Object body, String endpoint, AuthenticationConfigurer auth) { + + return call(name, http().POST().endpoint(endpoint, auth).body(body)); + } } diff --git a/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/spi/CallFnFluent.java b/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/spi/CallFnFluent.java index f576601b..99695aa4 100644 --- a/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/spi/CallFnFluent.java +++ b/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/spi/CallFnFluent.java @@ -21,9 +21,9 @@ public interface CallFnFluent, LIST> { - LIST callFn(String name, Consumer cfg); + LIST function(String name, Consumer cfg); - default LIST callFn(Consumer cfg) { - return this.callFn(UUID.randomUUID().toString(), cfg); + default LIST function(Consumer cfg) { + return this.function(UUID.randomUUID().toString(), cfg); } } 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 9eebb194..ee9f857e 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 @@ -15,6 +15,8 @@ */ package io.serverlessworkflow.fluent.func.spi; +import io.serverlessworkflow.fluent.func.FuncCallHttpTaskBuilder; +import io.serverlessworkflow.fluent.func.FuncCallOpenAPITaskBuilder; import io.serverlessworkflow.fluent.func.FuncCallTaskBuilder; import io.serverlessworkflow.fluent.func.FuncEmitTaskBuilder; import io.serverlessworkflow.fluent.func.FuncForTaskBuilder; @@ -22,6 +24,8 @@ import io.serverlessworkflow.fluent.func.FuncListenTaskBuilder; import io.serverlessworkflow.fluent.func.FuncSetTaskBuilder; import io.serverlessworkflow.fluent.func.FuncSwitchTaskBuilder; +import io.serverlessworkflow.fluent.spec.spi.CallHttpFluent; +import io.serverlessworkflow.fluent.spec.spi.CallOpenAPIFluent; import io.serverlessworkflow.fluent.spec.spi.EmitFluent; import io.serverlessworkflow.fluent.spec.spi.ForEachFluent; import io.serverlessworkflow.fluent.spec.spi.ForkFluent; @@ -29,8 +33,6 @@ import io.serverlessworkflow.fluent.spec.spi.SetFluent; import io.serverlessworkflow.fluent.spec.spi.SwitchFluent; -// TODO: implement the other builders, e.g. CallHTTP - public interface FuncDoFluent> extends SetFluent, EmitFluent, @@ -38,4 +40,6 @@ public interface FuncDoFluent> SwitchFluent, ForkFluent, ListenFluent, - CallFnFluent {} + CallFnFluent, + CallHttpFluent, + CallOpenAPIFluent {} diff --git a/experimental/fluent/func/src/test/java/io/serverlessworkflow/fluent/func/FuncDSLConsumeTest.java b/experimental/fluent/func/src/test/java/io/serverlessworkflow/fluent/func/FuncDSLConsumeTest.java index f7b12c9d..fb3150a0 100644 --- a/experimental/fluent/func/src/test/java/io/serverlessworkflow/fluent/func/FuncDSLConsumeTest.java +++ b/experimental/fluent/func/src/test/java/io/serverlessworkflow/fluent/func/FuncDSLConsumeTest.java @@ -32,7 +32,7 @@ class FuncDSLConsumeTest { @Test @DisplayName( "consume(name, Consumer, Class) produces CallTask and leaves output unchanged by contract") - void consume_produces_CallTask() { + void consume_produces_callTask() { AtomicReference sink = new AtomicReference<>(); Workflow wf = 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 843f6e6e..2425eb6d 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 @@ -18,11 +18,17 @@ import static io.serverlessworkflow.fluent.func.dsl.FuncDSL.emit; import static io.serverlessworkflow.fluent.func.dsl.FuncDSL.event; import static io.serverlessworkflow.fluent.func.dsl.FuncDSL.function; +import static io.serverlessworkflow.fluent.func.dsl.FuncDSL.get; import static io.serverlessworkflow.fluent.func.dsl.FuncDSL.listen; import static io.serverlessworkflow.fluent.func.dsl.FuncDSL.toOne; -import static org.junit.jupiter.api.Assertions.*; +import static io.serverlessworkflow.fluent.spec.dsl.DSL.auth; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import io.cloudevents.core.data.BytesCloudEventData; +import io.serverlessworkflow.api.types.CallHTTP; import io.serverlessworkflow.api.types.Export; import io.serverlessworkflow.api.types.FlowDirectiveEnum; import io.serverlessworkflow.api.types.Task; @@ -33,6 +39,7 @@ import io.serverlessworkflow.fluent.func.dsl.FuncDSL; import io.serverlessworkflow.fluent.func.dsl.FuncEmitSpec; import io.serverlessworkflow.fluent.func.dsl.FuncListenSpec; +import java.net.URI; import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; @@ -201,4 +208,226 @@ void switchWhenOrElse_jq_to_directive() { assertEquals( FlowDirectiveEnum.END, items.get(1).getSwitchCase().getThen().getFlowDirectiveEnum()); } + + @Test + void http_spec_via_call_builds_call_http_task() { + Workflow wf = + FuncWorkflowBuilder.workflow("http-call-spec") + .tasks( + FuncDSL.call("checkHealth", FuncDSL.http().GET().endpoint("http://service/health"))) + .build(); + + List items = wf.getDo(); + assertEquals(1, items.size()); + + Task t = items.get(0).getTask(); + assertNotNull(t.getCallTask(), "CallTask expected for HTTP call"); + + // HTTP-specific call + assertInstanceOf( + CallHTTP.class, t.getCallTask().get(), "CallTask should be an instance of CallHTTP"); + + CallHTTP http = (CallHTTP) t.getCallTask().get(); + assertEquals("GET", http.getWith().getMethod(), "HTTP method should be GET"); + assertEquals( + "http://service/health", + http.getWith().getEndpoint().getUriTemplate().getLiteralUri().toString(), + "endpoint should match the DSL endpoint"); + } + + @Test + @DisplayName("get(endpoint) convenience creates unnamed GET CallHTTP task") + void get_convenience_creates_http_get() { + Workflow wf = + FuncWorkflowBuilder.workflow("http-get-convenience") + .tasks(get("http://service/status")) + .build(); + + List items = wf.getDo(); + assertEquals(1, items.size()); + + Task t = items.get(0).getTask(); + assertNotNull(t.getCallTask(), "CallTask expected"); + + CallHTTP http = (CallHTTP) t.getCallTask().get(); + assertEquals("GET", http.getWith().getMethod()); + assertEquals( + "http://service/status", + http.getWith().getEndpoint().getUriTemplate().getLiteralUri().toString(), + "endpoint should be set from get(endpoint)"); + } + + @Test + @DisplayName("get(name, endpoint, auth -> auth.use(\"auth-id\")) wires authentication") + void get_named_with_authentication_uses_auth_policy() { + Workflow wf = + FuncWorkflowBuilder.workflow("http-get-auth") + .tasks(get("fetchUsers", "http://service/api/users", auth("user-service-auth"))) + .build(); + + List items = wf.getDo(); + assertEquals(1, items.size()); + + assertEquals("fetchUsers", items.get(0).getName(), "Task should use the provided name"); + Task t = items.get(0).getTask(); + assertNotNull(t.getCallTask(), "CallTask expected"); + + CallHTTP http = (CallHTTP) t.getCallTask().get(); + assertEquals("GET", http.getWith().getMethod()); + assertEquals( + "http://service/api/users", + http.getWith().getEndpoint().getUriTemplate().getLiteralUri().toString(), + "endpoint should be set from get(name, endpoint, auth)"); + + assertNotNull( + http.getWith().getEndpoint().getEndpointConfiguration().getAuthentication(), + "authentication should be configured"); + assertEquals( + "user-service-auth", + http.getWith() + .getEndpoint() + .getEndpointConfiguration() + .getAuthentication() + .getAuthenticationPolicyReference() + .getUse(), + "auth.use(\"...\") should set authentication.use"); + } + + @Test + @DisplayName("get(URI endpoint, auth) uses URI and authentication") + void get_with_uri_and_authentication() { + URI endpoint = URI.create("https://service.example.com/api/health"); + + Workflow wf = + FuncWorkflowBuilder.workflow("http-get-uri-auth") + .tasks(get("checkHealth", endpoint, auth -> auth.use("tls-auth"))) + .build(); + + List items = wf.getDo(); + assertEquals(1, items.size()); + + Task t = items.get(0).getTask(); + assertEquals("checkHealth", items.get(0).getName(), "Task should use the provided name"); + assertNotNull(t.getCallTask()); + + CallHTTP http = (CallHTTP) t.getCallTask().get(); + assertEquals("GET", http.getWith().getMethod()); + assertEquals( + endpoint.toString(), + http.getWith().getEndpoint().getUriTemplate().getLiteralUri().toString(), + "endpoint should be derived from URI"); + + assertNotNull(http.getWith().getEndpoint().getEndpointConfiguration().getAuthentication()); + assertEquals( + "tls-auth", + http.getWith() + .getEndpoint() + .getEndpointConfiguration() + .getAuthentication() + .getAuthenticationPolicyReference() + .getUse()); + } + + @Test + @DisplayName("post(body, endpoint) convenience creates POST CallHTTP with body") + void post_convenience_creates_http_post_with_body() { + Map body = Map.of("name", "Ricardo"); + + Workflow wf = + FuncWorkflowBuilder.workflow("http-post-convenience") + .tasks(FuncDSL.post(body, "http://service/api/users")) + .build(); + + List items = wf.getDo(); + assertEquals(1, items.size()); + + Task t = items.get(0).getTask(); + assertNotNull(t.getCallTask(), "CallTask expected"); + + CallHTTP http = (CallHTTP) t.getCallTask().get(); + assertEquals("POST", http.getWith().getMethod()); + assertEquals( + "http://service/api/users", + http.getWith().getEndpoint().getUriTemplate().getLiteralUri().toString(), + "endpoint should be set from post(body, endpoint)"); + + assertNotNull(http.getWith().getBody(), "Body should be set on POST"); + assertEquals(body, http.getWith().getBody(), "Body should match the provided payload"); + } + + @Test + @DisplayName("post(name, body, endpoint, auth) wires name, method, endpoint, body and auth") + void post_named_with_authentication() { + Map body = Map.of("id", 123, "status", "NEW"); + + Workflow wf = + FuncWorkflowBuilder.workflow("http-post-named-auth") + .tasks( + FuncDSL.post( + "createOrder", + body, + "https://orders.example.com/api/orders", + auth -> auth.use("orders-auth"))) + .build(); + + List items = wf.getDo(); + assertEquals(1, items.size()); + + Task t = items.get(0).getTask(); + assertEquals("createOrder", items.get(0).getName(), "Task should use the provided name"); + assertNotNull(t.getCallTask()); + + CallHTTP http = (CallHTTP) t.getCallTask().get(); + assertEquals("POST", http.getWith().getMethod()); + assertEquals( + "https://orders.example.com/api/orders", + http.getWith().getEndpoint().getUriTemplate().getLiteralUri().toString()); + assertEquals(body, http.getWith().getBody()); + + assertNotNull(http.getWith().getEndpoint().getEndpointConfiguration().getAuthentication()); + assertEquals( + "orders-auth", + http.getWith() + .getEndpoint() + .getEndpointConfiguration() + .getAuthentication() + .getAuthenticationPolicyReference() + .getUse()); + } + + @Test + @DisplayName("call(http(\"...\", auth)) reuses fluent HTTP spec in call(...)") + void call_with_preconfigured_http_spec() { + Workflow wf = + FuncWorkflowBuilder.workflow("http-call-preconfigured") + .tasks( + FuncDSL.call( + "preconfigured", + FuncDSL.http("http://service/api", auth -> auth.use("svc-auth")) + .POST() + .body(Map.of("foo", "bar")))) + .build(); + + List items = wf.getDo(); + assertEquals(1, items.size()); + + Task t = items.get(0).getTask(); + assertEquals("preconfigured", items.get(0).getName()); + assertNotNull(t.getCallTask()); + + CallHTTP http = (CallHTTP) t.getCallTask().get(); + assertEquals("POST", http.getWith().getMethod()); + assertEquals( + "http://service/api", + http.getWith().getEndpoint().getUriTemplate().getLiteralUri().toString()); + assertEquals( + "svc-auth", + http.getWith() + .getEndpoint() + .getEndpointConfiguration() + .getAuthentication() + .getAuthenticationPolicyReference() + .getUse()); + assertEquals(Map.of("foo", "bar"), http.getWith().getBody()); + } } diff --git a/experimental/lambda/src/test/java/io/serverless/workflow/impl/FluentDSLCallTest.java b/experimental/lambda/src/test/java/io/serverless/workflow/impl/FluentDSLCallTest.java index a3f08c1e..503dc253 100644 --- a/experimental/lambda/src/test/java/io/serverless/workflow/impl/FluentDSLCallTest.java +++ b/experimental/lambda/src/test/java/io/serverless/workflow/impl/FluentDSLCallTest.java @@ -34,7 +34,7 @@ void testJavaFunction() throws InterruptedException, ExecutionException { try (WorkflowApplication app = WorkflowApplication.builder().build()) { final Workflow workflow = FuncWorkflowBuilder.workflow("testJavaCall") - .tasks(tasks -> tasks.callFn(f -> f.function(JavaFunctions::getName))) + .tasks(tasks -> tasks.function(f -> f.function(JavaFunctions::getName))) .build(); assertThat( app.workflowDefinition(workflow) @@ -85,7 +85,7 @@ void testSwitch() throws InterruptedException, ExecutionException { switchOdd.onPredicate( item -> item.when(CallTest::isOdd).then(FlowDirectiveEnum.END))) - .callFn(callJava -> callJava.function(CallTest::zero))) + .function(callJava -> callJava.function(CallTest::zero))) .build(); WorkflowDefinition definition = app.workflowDefinition(workflow); diff --git a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/BaseWorkflowBuilder.java b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/BaseWorkflowBuilder.java index b81b2c22..8b042f48 100644 --- a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/BaseWorkflowBuilder.java +++ b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/BaseWorkflowBuilder.java @@ -79,6 +79,24 @@ public SELF use(Consumer useBuilderConsumer) { return self(); } + @SafeVarargs + public final SELF use(Consumer... configurers) { + if (configurers == null || configurers.length == 0) { + return self(); + } + return use(List.of(configurers.clone())); + } + + private SELF use(List> configurers) { + final UseBuilder builder = new UseBuilder(); + configurers.forEach( + c -> { + if (c != null) c.accept(builder); + }); + this.workflow.setUse(builder.build()); + return self(); + } + public SELF tasks(Consumer doTaskConsumer) { return appendDo(doTaskConsumer); } diff --git a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/CallHTTPTaskBuilder.java b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/CallHTTPTaskBuilder.java deleted file mode 100644 index 21b84fe9..00000000 --- a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/CallHTTPTaskBuilder.java +++ /dev/null @@ -1,202 +0,0 @@ -/* - * Copyright 2020-Present The Serverless Workflow Specification Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package io.serverlessworkflow.fluent.spec; - -import io.serverlessworkflow.api.types.CallHTTP; -import io.serverlessworkflow.api.types.Endpoint; -import io.serverlessworkflow.api.types.EndpointConfiguration; -import io.serverlessworkflow.api.types.HTTPArguments; -import io.serverlessworkflow.api.types.HTTPHeaders; -import io.serverlessworkflow.api.types.HTTPQuery; -import io.serverlessworkflow.api.types.Headers; -import io.serverlessworkflow.api.types.Query; -import io.serverlessworkflow.api.types.ReferenceableAuthenticationPolicy; -import io.serverlessworkflow.api.types.UriTemplate; -import java.net.URI; -import java.util.Map; -import java.util.function.Consumer; - -public class CallHTTPTaskBuilder extends TaskBaseBuilder { - - private final CallHTTP callHTTP; - - CallHTTPTaskBuilder() { - callHTTP = new CallHTTP(); - callHTTP.setWith(new HTTPArguments()); - callHTTP.getWith().setOutput(HTTPArguments.HTTPOutput.CONTENT); - super.setTask(this.callHTTP); - } - - @Override - protected CallHTTPTaskBuilder self() { - return this; - } - - public CallHTTPTaskBuilder method(String method) { - this.callHTTP.getWith().setMethod(method); - return this; - } - - public CallHTTPTaskBuilder endpoint(URI endpoint) { - this.callHTTP - .getWith() - .setEndpoint(new Endpoint().withUriTemplate(new UriTemplate().withLiteralUri(endpoint))); - return this; - } - - public CallHTTPTaskBuilder endpoint( - URI endpoint, Consumer auth) { - final AuthenticationPolicyUnionBuilder policy = new AuthenticationPolicyUnionBuilder(); - auth.accept(policy); - this.callHTTP - .getWith() - .setEndpoint( - new Endpoint() - .withEndpointConfiguration( - new EndpointConfiguration() - .withAuthentication( - new ReferenceableAuthenticationPolicy() - .withAuthenticationPolicy(policy.build()))) - .withUriTemplate(new UriTemplate().withLiteralUri(endpoint))); - return this; - } - - public CallHTTPTaskBuilder endpoint(String expr) { - this.callHTTP.getWith().setEndpoint(new Endpoint().withRuntimeExpression(expr)); - return this; - } - - public CallHTTPTaskBuilder endpoint( - String expr, Consumer auth) { - final AuthenticationPolicyUnionBuilder policy = new AuthenticationPolicyUnionBuilder(); - auth.accept(policy); - this.callHTTP - .getWith() - .setEndpoint( - new Endpoint() - .withEndpointConfiguration( - new EndpointConfiguration() - .withAuthentication( - new ReferenceableAuthenticationPolicy() - .withAuthenticationPolicy(policy.build()))) - .withRuntimeExpression(expr)); - return this; - } - - public CallHTTPTaskBuilder headers(String expr) { - this.callHTTP.getWith().setHeaders(new Headers().withRuntimeExpression(expr)); - return this; - } - - public CallHTTPTaskBuilder headers(Consumer consumer) { - HTTPHeadersBuilder hb = new HTTPHeadersBuilder(); - consumer.accept(hb); - if (callHTTP.getWith().getHeaders() != null - && callHTTP.getWith().getHeaders().getHTTPHeaders() != null) { - Headers h = callHTTP.getWith().getHeaders(); - Headers built = hb.build(); - built - .getHTTPHeaders() - .getAdditionalProperties() - .forEach((k, v) -> h.getHTTPHeaders().setAdditionalProperty(k, v)); - } else { - callHTTP.getWith().setHeaders(hb.build()); - } - - return this; - } - - public CallHTTPTaskBuilder headers(Map headers) { - HTTPHeadersBuilder hb = new HTTPHeadersBuilder(); - hb.headers(headers); - callHTTP.getWith().setHeaders(hb.build()); - return this; - } - - public CallHTTPTaskBuilder body(Object body) { - this.callHTTP.getWith().setBody(body); - return this; - } - - public CallHTTPTaskBuilder query(String expr) { - this.callHTTP.getWith().setQuery(new Query().withRuntimeExpression(expr)); - return this; - } - - public CallHTTPTaskBuilder query(Consumer consumer) { - HTTPQueryBuilder queryBuilder = new HTTPQueryBuilder(); - consumer.accept(queryBuilder); - callHTTP.getWith().setQuery(queryBuilder.build()); - return this; - } - - public CallHTTPTaskBuilder query(Map query) { - HTTPQueryBuilder httpQueryBuilder = new HTTPQueryBuilder(); - httpQueryBuilder.queries(query); - callHTTP.getWith().setQuery(httpQueryBuilder.build()); - return this; - } - - public CallHTTPTaskBuilder redirect(boolean redirect) { - callHTTP.getWith().setRedirect(redirect); - return this; - } - - public CallHTTPTaskBuilder output(HTTPArguments.HTTPOutput output) { - callHTTP.getWith().setOutput(output); - return this; - } - - public CallHTTP build() { - return callHTTP; - } - - public static class HTTPQueryBuilder { - private final HTTPQuery httpQuery = new HTTPQuery(); - - public HTTPQueryBuilder query(String name, String value) { - httpQuery.setAdditionalProperty(name, value); - return this; - } - - public HTTPQueryBuilder queries(Map headers) { - headers.forEach(httpQuery::setAdditionalProperty); - return this; - } - - public Query build() { - return new Query().withHTTPQuery(httpQuery); - } - } - - public static class HTTPHeadersBuilder { - private final HTTPHeaders httpHeaders = new HTTPHeaders(); - - public HTTPHeadersBuilder header(String name, String value) { - httpHeaders.setAdditionalProperty(name, value); - return this; - } - - public HTTPHeadersBuilder headers(Map headers) { - headers.forEach(httpHeaders::setAdditionalProperty); - return this; - } - - public Headers build() { - return new Headers().withHTTPHeaders(httpHeaders); - } - } -} diff --git a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/CallHttpTaskBuilder.java b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/CallHttpTaskBuilder.java new file mode 100644 index 00000000..0ff056da --- /dev/null +++ b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/CallHttpTaskBuilder.java @@ -0,0 +1,34 @@ +/* + * 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.fluent.spec; + +import io.serverlessworkflow.api.types.CallHTTP; +import io.serverlessworkflow.api.types.HTTPArguments; +import io.serverlessworkflow.fluent.spec.spi.CallHttpTaskFluent; + +public class CallHttpTaskBuilder extends TaskBaseBuilder + implements CallHttpTaskFluent { + + protected CallHttpTaskBuilder() { + final CallHTTP callHTTP = new CallHTTP().withWith(new HTTPArguments()); + super.setTask(callHTTP); + } + + @Override + public CallHttpTaskBuilder self() { + return this; + } +} diff --git a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/CallOpenAPITaskBuilder.java b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/CallOpenAPITaskBuilder.java new file mode 100644 index 00000000..cc7ebce9 --- /dev/null +++ b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/CallOpenAPITaskBuilder.java @@ -0,0 +1,36 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.serverlessworkflow.fluent.spec; + +import io.serverlessworkflow.api.types.CallOpenAPI; +import io.serverlessworkflow.api.types.OpenAPIArguments; +import io.serverlessworkflow.api.types.WithOpenAPIParameters; +import io.serverlessworkflow.fluent.spec.spi.CallOpenAPITaskFluent; + +public class CallOpenAPITaskBuilder extends TaskBaseBuilder + implements CallOpenAPITaskFluent { + + CallOpenAPITaskBuilder() { + final CallOpenAPI callOpenAPI = new CallOpenAPI(); + callOpenAPI.setWith(new OpenAPIArguments().withParameters(new WithOpenAPIParameters())); + super.setTask(callOpenAPI); + } + + @Override + public CallOpenAPITaskBuilder self() { + return this; + } +} diff --git a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/DoTaskBuilder.java b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/DoTaskBuilder.java index 669b580f..98f599a7 100644 --- a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/DoTaskBuilder.java +++ b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/DoTaskBuilder.java @@ -31,8 +31,8 @@ protected DoTaskBuilder self() { } @Override - public DoTaskBuilder callHTTP(String name, Consumer itemsConfigurer) { - this.listBuilder().callHTTP(name, itemsConfigurer); + public DoTaskBuilder http(String name, Consumer itemsConfigurer) { + this.listBuilder().http(name, itemsConfigurer); return this; } @@ -91,4 +91,10 @@ public DoTaskBuilder tryCatch( this.listBuilder().tryCatch(name, itemsConfigurer); return this; } + + @Override + public DoTaskBuilder openapi(String name, Consumer itemsConfigurer) { + this.listBuilder().openapi(name, itemsConfigurer); + return this; + } } diff --git a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/AuthenticationPolicyUnionBuilder.java b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/ReferenceableAuthenticationPolicyBuilder.java similarity index 71% rename from fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/AuthenticationPolicyUnionBuilder.java rename to fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/ReferenceableAuthenticationPolicyBuilder.java index 82f84a74..9c304023 100644 --- a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/AuthenticationPolicyUnionBuilder.java +++ b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/ReferenceableAuthenticationPolicyBuilder.java @@ -15,17 +15,21 @@ */ package io.serverlessworkflow.fluent.spec; +import io.serverlessworkflow.api.types.AuthenticationPolicyReference; import io.serverlessworkflow.api.types.AuthenticationPolicyUnion; +import io.serverlessworkflow.api.types.ReferenceableAuthenticationPolicy; import java.util.function.Consumer; -public class AuthenticationPolicyUnionBuilder { +public class ReferenceableAuthenticationPolicyBuilder { final AuthenticationPolicyUnion authenticationPolicy; + final AuthenticationPolicyReference authenticationPolicyReference; - AuthenticationPolicyUnionBuilder() { + public ReferenceableAuthenticationPolicyBuilder() { this.authenticationPolicy = new AuthenticationPolicyUnion(); + this.authenticationPolicyReference = new AuthenticationPolicyReference(); } - public AuthenticationPolicyUnionBuilder basic( + public ReferenceableAuthenticationPolicyBuilder basic( Consumer basicConsumer) { final BasicAuthenticationPolicyBuilder basicAuthenticationPolicyBuilder = new BasicAuthenticationPolicyBuilder(); @@ -35,7 +39,7 @@ public AuthenticationPolicyUnionBuilder basic( return this; } - public AuthenticationPolicyUnionBuilder bearer( + public ReferenceableAuthenticationPolicyBuilder bearer( Consumer bearerConsumer) { final BearerAuthenticationPolicyBuilder bearerAuthenticationPolicyBuilder = new BearerAuthenticationPolicyBuilder(); @@ -45,7 +49,7 @@ public AuthenticationPolicyUnionBuilder bearer( return this; } - public AuthenticationPolicyUnionBuilder digest( + public ReferenceableAuthenticationPolicyBuilder digest( Consumer digestConsumer) { final DigestAuthenticationPolicyBuilder digestAuthenticationPolicyBuilder = new DigestAuthenticationPolicyBuilder(); @@ -55,7 +59,7 @@ public AuthenticationPolicyUnionBuilder digest( return this; } - public AuthenticationPolicyUnionBuilder oauth2( + public ReferenceableAuthenticationPolicyBuilder oauth2( Consumer oauth2Consumer) { final OAuth2AuthenticationPolicyBuilder oauth2AuthenticationPolicyBuilder = new OAuth2AuthenticationPolicyBuilder(); @@ -65,7 +69,7 @@ public AuthenticationPolicyUnionBuilder oauth2( return this; } - public AuthenticationPolicyUnionBuilder openIDConnect( + public ReferenceableAuthenticationPolicyBuilder openIDConnect( Consumer openIdConnectConsumer) { final OpenIdConnectAuthenticationPolicyBuilder builder = new OpenIdConnectAuthenticationPolicyBuilder(); @@ -74,7 +78,15 @@ public AuthenticationPolicyUnionBuilder openIDConnect( return this; } - public AuthenticationPolicyUnion build() { - return authenticationPolicy; + public ReferenceableAuthenticationPolicyBuilder use(String use) { + this.authenticationPolicyReference.setUse(use); + return this; + } + + public ReferenceableAuthenticationPolicy build() { + final ReferenceableAuthenticationPolicy policy = new ReferenceableAuthenticationPolicy(); + policy.setAuthenticationPolicy(this.authenticationPolicy); + policy.setAuthenticationPolicyReference(this.authenticationPolicyReference); + return policy; } } diff --git a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/TaskItemListBuilder.java b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/TaskItemListBuilder.java index 5cfba36c..6a0736b0 100644 --- a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/TaskItemListBuilder.java +++ b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/TaskItemListBuilder.java @@ -117,12 +117,33 @@ public TaskItemListBuilder tryCatch( } @Override - public TaskItemListBuilder callHTTP(String name, Consumer itemsConfigurer) { + public TaskItemListBuilder http(String name, Consumer itemsConfigurer) { name = defaultNameAndRequireConfig(name, itemsConfigurer); - final CallHTTPTaskBuilder callHTTPBuilder = new CallHTTPTaskBuilder(); + + final CallHttpTaskBuilder callHTTPBuilder = new CallHttpTaskBuilder(); itemsConfigurer.accept(callHTTPBuilder); - return addTaskItem( - new TaskItem( - name, new Task().withCallTask(new CallTask().withCallHTTP(callHTTPBuilder.build())))); + + final CallTask callTask = new CallTask(); + callTask.setCallHTTP(callHTTPBuilder.build()); + final Task task = new Task(); + task.setCallTask(callTask); + + return addTaskItem(new TaskItem(name, task)); + } + + @Override + public TaskItemListBuilder openapi( + String name, Consumer itemsConfigurer) { + name = defaultNameAndRequireConfig(name, itemsConfigurer); + + final CallOpenAPITaskBuilder callOpenAPIBuilder = new CallOpenAPITaskBuilder(); + itemsConfigurer.accept(callOpenAPIBuilder); + + final CallTask callTask = new CallTask(); + callTask.setCallOpenAPI(callOpenAPIBuilder.build()); + final Task task = new Task(); + task.setCallTask(callTask); + + return addTaskItem(new TaskItem(name, task)); } } diff --git a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/UseAuthenticationsBuilder.java b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/UseAuthenticationsBuilder.java index d865c802..0c958092 100644 --- a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/UseAuthenticationsBuilder.java +++ b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/UseAuthenticationsBuilder.java @@ -27,10 +27,11 @@ public class UseAuthenticationsBuilder { } public UseAuthenticationsBuilder authentication( - String name, Consumer authenticationConsumer) { - final AuthenticationPolicyUnionBuilder builder = new AuthenticationPolicyUnionBuilder(); + String name, Consumer authenticationConsumer) { + final ReferenceableAuthenticationPolicyBuilder builder = + new ReferenceableAuthenticationPolicyBuilder(); authenticationConsumer.accept(builder); - this.authentication.setAdditionalProperty(name, builder.build()); + this.authentication.setAdditionalProperty(name, builder.build().getAuthenticationPolicy()); return this; } diff --git a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/UseBuilder.java b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/UseBuilder.java index 66833111..d3b9bd55 100644 --- a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/UseBuilder.java +++ b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/UseBuilder.java @@ -16,6 +16,7 @@ package io.serverlessworkflow.fluent.spec; import io.serverlessworkflow.api.types.Use; +import io.serverlessworkflow.api.types.UseAuthentications; import java.util.List; import java.util.function.Consumer; @@ -25,6 +26,7 @@ public class UseBuilder { UseBuilder() { this.use = new Use(); + this.use.setAuthentications(new UseAuthentications()); } public UseBuilder secrets(final String... secrets) { @@ -37,7 +39,11 @@ public UseBuilder secrets(final String... secrets) { public UseBuilder authentications(Consumer authenticationsConsumer) { final UseAuthenticationsBuilder builder = new UseAuthenticationsBuilder(); authenticationsConsumer.accept(builder); - this.use.setAuthentications(builder.build()); + final UseAuthentications useAuthentications = builder.build(); + this.use + .getAuthentications() + .getAdditionalProperties() + .putAll(useAuthentications.getAdditionalProperties()); return this; } diff --git a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/configurers/AuthenticationConfigurer.java b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/configurers/AuthenticationConfigurer.java index 97b9bd4b..cdd481ce 100644 --- a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/configurers/AuthenticationConfigurer.java +++ b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/configurers/AuthenticationConfigurer.java @@ -15,8 +15,9 @@ */ package io.serverlessworkflow.fluent.spec.configurers; -import io.serverlessworkflow.fluent.spec.AuthenticationPolicyUnionBuilder; +import io.serverlessworkflow.fluent.spec.ReferenceableAuthenticationPolicyBuilder; import java.util.function.Consumer; @FunctionalInterface -public interface AuthenticationConfigurer extends Consumer {} +public interface AuthenticationConfigurer + extends Consumer {} diff --git a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/configurers/CallHTTPConfigurer.java b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/configurers/CallHttpConfigurer.java similarity index 85% rename from fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/configurers/CallHTTPConfigurer.java rename to fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/configurers/CallHttpConfigurer.java index 11799b47..d1397d05 100644 --- a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/configurers/CallHTTPConfigurer.java +++ b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/configurers/CallHttpConfigurer.java @@ -15,8 +15,8 @@ */ package io.serverlessworkflow.fluent.spec.configurers; -import io.serverlessworkflow.fluent.spec.CallHTTPTaskBuilder; +import io.serverlessworkflow.fluent.spec.CallHttpTaskBuilder; import java.util.function.Consumer; @FunctionalInterface -public interface CallHTTPConfigurer extends Consumer {} +public interface CallHttpConfigurer extends Consumer {} diff --git a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/configurers/CallOpenAPIConfigurer.java b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/configurers/CallOpenAPIConfigurer.java new file mode 100644 index 00000000..730b3ca1 --- /dev/null +++ b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/configurers/CallOpenAPIConfigurer.java @@ -0,0 +1,22 @@ +/* + * 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.fluent.spec.configurers; + +import io.serverlessworkflow.fluent.spec.CallOpenAPITaskBuilder; +import java.util.function.Consumer; + +@FunctionalInterface +public interface CallOpenAPIConfigurer extends Consumer {} diff --git a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/configurers/UseConfigurer.java b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/configurers/UseConfigurer.java new file mode 100644 index 00000000..5fe1781d --- /dev/null +++ b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/configurers/UseConfigurer.java @@ -0,0 +1,22 @@ +/* + * 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.fluent.spec.configurers; + +import io.serverlessworkflow.fluent.spec.UseBuilder; +import java.util.function.Consumer; + +@FunctionalInterface +public interface UseConfigurer extends Consumer {} diff --git a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/dsl/CallHTTPSpec.java b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/dsl/BaseCallHttpSpec.java similarity index 55% rename from fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/dsl/CallHTTPSpec.java rename to fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/dsl/BaseCallHttpSpec.java index 83bec629..1348a0a3 100644 --- a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/dsl/CallHTTPSpec.java +++ b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/dsl/BaseCallHttpSpec.java @@ -15,89 +15,95 @@ */ package io.serverlessworkflow.fluent.spec.dsl; -import io.serverlessworkflow.fluent.spec.CallHTTPTaskBuilder; import io.serverlessworkflow.fluent.spec.configurers.AuthenticationConfigurer; -import io.serverlessworkflow.fluent.spec.configurers.CallHTTPConfigurer; +import io.serverlessworkflow.fluent.spec.spi.CallHttpTaskFluent; import java.net.URI; import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.function.Consumer; -public final class CallHTTPSpec implements CallHTTPConfigurer { +public abstract class BaseCallHttpSpec> { - private final List steps = new ArrayList<>(); + private final List>> steps = new ArrayList<>(); - public CallHTTPSpec GET() { + protected abstract SELF self(); + + public SELF GET() { steps.add(c -> c.method("GET")); - return this; + return self(); } - public CallHTTPSpec POST() { + public SELF POST() { steps.add(c -> c.method("POST")); - return this; + return self(); } - public CallHTTPSpec acceptJSON() { + public SELF acceptJSON() { return header("Accept", "application/json"); } - public CallHTTPSpec endpoint(String urlExpr) { + public SELF endpoint(String urlExpr) { steps.add(b -> b.endpoint(urlExpr)); - return this; + return self(); } - public CallHTTPSpec endpoint(String urlExpr, AuthenticationConfigurer auth) { + public SELF endpoint(String urlExpr, AuthenticationConfigurer auth) { steps.add(b -> b.endpoint(urlExpr, auth)); - return this; + return self(); } - public CallHTTPSpec uri(String url) { + public SELF uri(String url) { steps.add(b -> b.endpoint(URI.create(url))); - return this; + return self(); } - public CallHTTPSpec uri(String url, AuthenticationConfigurer auth) { + public SELF uri(String url, AuthenticationConfigurer auth) { steps.add(b -> b.endpoint(URI.create(url), auth)); - return this; + return self(); } - public CallHTTPSpec uri(URI uri) { + public SELF uri(URI uri) { steps.add(b -> b.endpoint(uri)); - return this; + return self(); } - public CallHTTPSpec uri(URI uri, AuthenticationConfigurer auth) { + public SELF uri(URI uri, AuthenticationConfigurer auth) { steps.add(b -> b.endpoint(uri, auth)); - return this; + return self(); } - public CallHTTPSpec body(String bodyExpr) { + public SELF body(String bodyExpr) { steps.add(c -> c.body(bodyExpr)); - return this; + return self(); } - public CallHTTPSpec body(Map body) { + public SELF body(Map body) { steps.add(c -> c.body(body)); - return this; + return self(); + } + + public SELF body(Object bodyExpr) { + steps.add(c -> c.body(bodyExpr)); + return self(); } - public CallHTTPSpec method(String method) { + public SELF method(String method) { steps.add(b -> b.method(method)); - return this; + return self(); } - public CallHTTPSpec header(String name, String value) { + public SELF header(String name, String value) { steps.add(c -> c.headers(h -> h.header(name, value))); - return this; + return self(); } - public CallHTTPSpec headers(Map headers) { + public SELF headers(Map headers) { steps.add(b -> b.headers(headers)); - return this; + return self(); } - @Override - public void accept(CallHTTPTaskBuilder b) { + public void accept(CallHttpTaskFluent b) { for (var s : steps) s.accept(b); } } diff --git a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/dsl/CallHttpSpec.java b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/dsl/CallHttpSpec.java new file mode 100644 index 00000000..4c9b85d3 --- /dev/null +++ b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/dsl/CallHttpSpec.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.fluent.spec.dsl; + +import io.serverlessworkflow.fluent.spec.CallHttpTaskBuilder; +import io.serverlessworkflow.fluent.spec.configurers.CallHttpConfigurer; + +public final class CallHttpSpec extends BaseCallHttpSpec + implements CallHttpConfigurer { + + @Override + protected CallHttpSpec self() { + return this; + } + + @Override + public void accept(CallHttpTaskBuilder callHttpTaskBuilder) { + super.accept(callHttpTaskBuilder); + } +} diff --git a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/dsl/CallOpenAPISpec.java b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/dsl/CallOpenAPISpec.java new file mode 100644 index 00000000..8da74f1c --- /dev/null +++ b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/dsl/CallOpenAPISpec.java @@ -0,0 +1,89 @@ +/* + * 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.fluent.spec.dsl; + +import io.serverlessworkflow.api.types.OpenAPIArguments; +import io.serverlessworkflow.fluent.spec.CallOpenAPITaskBuilder; +import io.serverlessworkflow.fluent.spec.configurers.AuthenticationConfigurer; +import io.serverlessworkflow.fluent.spec.configurers.CallOpenAPIConfigurer; +import io.serverlessworkflow.fluent.spec.spi.CallOpenAPITaskFluent; +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +public final class CallOpenAPISpec implements CallOpenAPIConfigurer { + + private final List>> steps = new ArrayList<>(); + + public CallOpenAPISpec document(String uri) { + steps.add(b -> b.document(uri)); + return this; + } + + public CallOpenAPISpec document(String uri, AuthenticationConfigurer authenticationConfigurer) { + steps.add(b -> b.document(uri, authenticationConfigurer)); + return this; + } + + public CallOpenAPISpec document(URI uri) { + steps.add(b -> b.document(uri)); + return this; + } + + public CallOpenAPISpec document(URI uri, AuthenticationConfigurer authenticationConfigurer) { + steps.add(b -> b.document(uri, authenticationConfigurer)); + return this; + } + + public CallOpenAPISpec operation(String operationId) { + steps.add(b -> b.operation(operationId)); + return this; + } + + public CallOpenAPISpec parameters(Map params) { + steps.add(b -> b.parameters(params)); + return this; + } + + public CallOpenAPISpec parameter(String name, String value) { + steps.add(b -> b.parameter(name, value)); + return this; + } + + public CallOpenAPISpec redirect(boolean redirect) { + steps.add(b -> b.redirect(redirect)); + return this; + } + + public CallOpenAPISpec authentication(AuthenticationConfigurer authenticationConfigurer) { + steps.add(b -> b.authentication(authenticationConfigurer)); + return this; + } + + public CallOpenAPISpec output(OpenAPIArguments.WithOpenAPIOutput output) { + steps.add(b -> b.output(output)); + return this; + } + + @Override + public void accept(CallOpenAPITaskBuilder builder) { + for (var s : steps) { + s.accept(builder); + } + } +} diff --git a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/dsl/DSL.java b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/dsl/DSL.java index 0863ef25..955d9db5 100644 --- a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/dsl/DSL.java +++ b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/dsl/DSL.java @@ -22,7 +22,8 @@ import io.serverlessworkflow.fluent.spec.TaskItemListBuilder; import io.serverlessworkflow.fluent.spec.TryTaskBuilder; import io.serverlessworkflow.fluent.spec.configurers.AuthenticationConfigurer; -import io.serverlessworkflow.fluent.spec.configurers.CallHTTPConfigurer; +import io.serverlessworkflow.fluent.spec.configurers.CallHttpConfigurer; +import io.serverlessworkflow.fluent.spec.configurers.CallOpenAPIConfigurer; import io.serverlessworkflow.fluent.spec.configurers.ForEachConfigurer; import io.serverlessworkflow.fluent.spec.configurers.ListenConfigurer; import io.serverlessworkflow.fluent.spec.configurers.RaiseConfigurer; @@ -38,76 +39,355 @@ import java.util.Objects; import java.util.function.Consumer; +/** + * High-level Java DSL shortcuts for building workflows. + * + *

This class exposes small, composable helpers that: + * + *

    + *
  • Build HTTP and OpenAPI call specs. + *
  • Configure {@code use} blocks (secrets and authentications). + *
  • Define switch, listen, event, try/catch and raise recipes. + *
  • Compose tasks lists and common task patterns. + *
+ * + *

All methods are static and designed to be used with static imports. + */ public final class DSL { private DSL() {} // ---- Convenient shortcuts ----// - public static CallHTTPSpec http() { - return new CallHTTPSpec(); - } - + /** + * Create a new HTTP call specification to be used with {@link #call(CallHttpConfigurer)} or + * {@link #call(io.serverlessworkflow.fluent.func.dsl.FuncCallHttpSpec)}. + * + *

Typical usage: + * + *

{@code
+   * tasks(
+   *   call(
+   *     http()
+   *       .GET()
+   *       .endpoint("http://service/api")
+   *   )
+   * );
+   * }
+ * + * @return a new {@link CallHttpSpec} instance + */ + public static CallHttpSpec http() { + return new CallHttpSpec(); + } + + /** + * Create a new OpenAPI call specification to be used with {@link #call(CallOpenAPIConfigurer)}. + * + *

Typical usage: + * + *

{@code
+   * tasks(
+   *   call(
+   *     openapi()
+   *       .document("http://service/openapi.json")
+   *       .operation("getUser")
+   *   )
+   * );
+   * }
+ * + * @return a new {@link CallOpenAPISpec} instance + */ + public static CallOpenAPISpec openapi() { + return new CallOpenAPISpec(); + } + + /** + * Convenience for defining a {@code use} block with a single secret. + * + *

Example: + * + *

{@code
+   * workflow()
+   *   .use(secret("db-password"))
+   *   .build();
+   * }
+ * + * @param secret secret identifier to add to the workflow + * @return a {@link UseSpec} preconfigured with the given secret + */ + public static UseSpec secret(String secret) { + return new UseSpec().secret(secret); + } + + /** + * Convenience for defining a {@code use} block with multiple secrets. + * + *

Example: + * + *

{@code
+   * workflow()
+   *   .use(secrets("db-password", "api-key"))
+   *   .build();
+   * }
+ * + * @param secret one or more secret identifiers + * @return a {@link UseSpec} preconfigured with the given secrets + */ + public static UseSpec secrets(String... secret) { + return new UseSpec().secrets(secret); + } + + /** + * Convenience for defining a single reusable authentication policy by name. + * + *

Example: + * + *

{@code
+   * workflow()
+   *   .use(auth("basic-auth", basic("user", "pass")));
+   * }
+ * + * @param name logical authentication policy name + * @param auth authentication configurer (e.g. {@link #basic(String, String)}) + * @return a {@link UseSpec} preconfigured with the given authentication + */ + public static UseSpec auth(String name, AuthenticationConfigurer auth) { + return new UseSpec().auth(name, auth); + } + + /** + * Create an empty {@link UseSpec} for incremental configuration. + * + *

Example: + * + *

{@code
+   * workflow()
+   *   .use(
+   *     use()
+   *       .secret("db-password")
+   *       .auth("basic-auth", basic("u", "p"))
+   *   );
+   * }
+ * + * @return a new, empty {@link UseSpec} + */ + public static UseSpec use() { + return new UseSpec(); + } + + /** + * Create an empty {@link SwitchSpec} for building switch cases. + * + *

Typical usage is via chaining methods on {@link SwitchSpec} and passing it to {@link + * #switchCase(SwitchConfigurer)}. + * + * @return a new {@link SwitchSpec} + */ public static SwitchSpec cases() { return new SwitchSpec(); } + /** + * Start building a {@code listen} specification without a predefined strategy. + * + *

Use methods on {@link ListenSpec} like {@code one()}, {@code any()} or {@code all()} and + * then pass the spec to {@link #listen(ListenConfigurer)}. + * + * @return a new {@link ListenSpec} + */ public static ListenSpec to() { return new ListenSpec(); } + /** + * Start building an event emission specification. + * + *

Use methods on {@link EventSpec} to define event type and payload, and pass it to {@link + * #emit(Consumer)}. + * + * @return a new {@link EventSpec} + */ public static EventSpec event() { return new EventSpec(); } + /** + * Start building a {@code try/catch} specification for use with {@link #tryCatch(TryConfigurer)}. + * + *

Example: + * + *

{@code
+   * tasks(
+   *   tryCatch(
+   *     tryCatch()
+   *       .doTasks(...)
+   *       .on(catchWhen("jqExpr", retryWhen(...), tasks(...)))
+   *   )
+   * );
+   * }
+ * + * @return a new {@link TrySpec} + */ public static TrySpec tryCatch() { return new TrySpec(); } + /** + * Build a {@link TryCatchConfigurer} that catches when a given expression matches, configures + * retry, and runs the given tasks as the catch body. + * + * @param when jq-style condition expression + * @param retry retry strategy configurer + * @param doTasks one or more task configurers to execute in the catch block + * @return a {@link TryCatchConfigurer} to be used inside {@link TrySpec} + */ public static TryCatchConfigurer catchWhen( String when, RetryConfigurer retry, TasksConfigurer... doTasks) { return c -> c.when(when).retry(retry).doTasks(tasks(doTasks)); } + /** + * Build a {@link TryCatchConfigurer} that catches when the given expression does not + * match, configures retry, and runs the given tasks as the catch body. + * + * @param when jq-style condition expression to exclude + * @param retry retry strategy configurer + * @param doTasks one or more task configurers to execute in the catch block + * @return a {@link TryCatchConfigurer} to be used inside {@link TrySpec} + */ public static TryCatchConfigurer catchExceptWhen( String when, RetryConfigurer retry, TasksConfigurer... doTasks) { return c -> c.exceptWhen(when).retry(retry).doTasks(tasks(doTasks)); } + /** + * Build a basic retry strategy that retries when the given condition is true and respects a + * duration limit. + * + * @param when jq-style condition expression + * @param limitDuration duration in ISO-8601 or spec-compatible format + * @return a {@link RetryConfigurer} representing the retry strategy + */ public static RetryConfigurer retryWhen(String when, String limitDuration) { return r -> r.when(when).limit(l -> l.duration(limitDuration)); } + /** + * Build a retry strategy that retries when the given condition does not hold and + * respects a duration limit. + * + * @param when jq-style condition expression to exclude + * @param limitDuration duration in ISO-8601 or spec-compatible format + * @return a {@link RetryConfigurer} representing the retry strategy + */ public static RetryConfigurer retryExceptWhen(String when, String limitDuration) { return r -> r.exceptWhen(when).limit(l -> l.duration(limitDuration)); } + /** + * Build an error filter for the {@code catch} clause based on a concrete error type URI and HTTP + * status. + * + * @param errType error type URI + * @param status expected HTTP status code + * @return a consumer to configure {@link TryTaskBuilder.CatchErrorsBuilder} + */ public static Consumer errorFilter(URI errType, int status) { return e -> e.type(errType.toString()).status(status); } + /** + * Build an error filter for the {@code catch} clause using a standard {@link Errors.Standard} + * error and HTTP status. + * + * @param errType standard error enum + * @param status expected HTTP status code + * @return a consumer to configure {@link TryTaskBuilder.CatchErrorsBuilder} + */ public static Consumer errorFilter( Errors.Standard errType, int status) { return e -> e.type(errType.toString()).status(status); } + /** + * Create an {@link AuthenticationConfigurer} that references a previously defined authentication + * policy by name in the {@code use} block. + * + *

Equivalent to setting {@code authentication.use(name)}. + * + * @param authName the name of a reusable authentication policy + * @return an {@link AuthenticationConfigurer} that sets {@code use(authName)} + */ + public static AuthenticationConfigurer auth(String authName) { + return a -> a.use(authName); + } + + /** + * Build a BASIC authentication configurer with username and password. + * + *

Typical usage: + * + *

{@code
+   * auth("basic-auth", basic("user", "pass"))
+   * }
+ * + * @param username BASIC auth username + * @param password BASIC auth password + * @return an {@link AuthenticationConfigurer} for BASIC authentication + */ public static AuthenticationConfigurer basic(String username, String password) { return a -> a.basic(b -> b.username(username).password(password)); } + /** + * Build a Bearer token authentication configurer. + * + *

Typical usage: + * + *

{@code
+   * auth("bearer-auth", bearer("token-123"))
+   * }
+ * + * @param token bearer token + * @return an {@link AuthenticationConfigurer} for Bearer authentication + */ public static AuthenticationConfigurer bearer(String token) { return a -> a.bearer(b -> b.token(token)); } + /** + * Build a Digest authentication configurer with username and password. + * + * @param username digest auth username + * @param password digest auth password + * @return an {@link AuthenticationConfigurer} for Digest authentication + */ public static AuthenticationConfigurer digest(String username, String password) { return a -> a.digest(d -> d.username(username).password(password)); } + /** + * Build an OpenID Connect (OIDC) authentication configurer without client credentials. + * + * @param authority OIDC authority/issuer URL + * @param grant OAuth2 grant type + * @return an {@link AuthenticationConfigurer} using OIDC + */ public static AuthenticationConfigurer oidc( String authority, OAuth2AuthenticationData.OAuth2AuthenticationDataGrant grant) { return a -> a.openIDConnect(o -> o.authority(authority).grant(grant)); } + /** + * Build an OpenID Connect (OIDC) authentication configurer with client credentials. + * + * @param authority OIDC authority/issuer URL + * @param grant OAuth2 grant type + * @param clientId client identifier + * @param clientSecret client secret + * @return an {@link AuthenticationConfigurer} using OIDC with client credentials + */ public static AuthenticationConfigurer oidc( String authority, OAuth2AuthenticationData.OAuth2AuthenticationDataGrant grant, @@ -123,11 +403,29 @@ public static AuthenticationConfigurer oidc( // TODO: we may create an OIDCSpec for chained builders if necessary + /** + * Alias for {@link #oidc(String, OAuth2AuthenticationData.OAuth2AuthenticationDataGrant)} using + * OAuth2 semantics. + * + * @param authority OAuth2/OIDC authority URL + * @param grant OAuth2 grant type + * @return an {@link AuthenticationConfigurer} configured as OAuth2 + */ public static AuthenticationConfigurer oauth2( String authority, OAuth2AuthenticationData.OAuth2AuthenticationDataGrant grant) { return a -> a.openIDConnect(o -> o.authority(authority).grant(grant)); } + /** + * Alias for {@link #oidc(String, OAuth2AuthenticationData.OAuth2AuthenticationDataGrant, String, + * String)} using OAuth2 naming. + * + * @param authority OAuth2/OIDC authority URL + * @param grant OAuth2 grant type + * @param clientId client identifier + * @param clientSecret client secret + * @return an {@link AuthenticationConfigurer} configured as OAuth2 with client credentials + */ public static AuthenticationConfigurer oauth2( String authority, OAuth2AuthenticationData.OAuth2AuthenticationDataGrant grant, @@ -141,128 +439,326 @@ public static AuthenticationConfigurer oauth2( .client(c -> c.id(clientId).secret(clientSecret))); } + /** + * Build a {@link RaiseSpec} for an error with a string type expression and HTTP status. + * + * @param errExpr error type expression (URI or logical id) + * @param status HTTP status code + * @return a {@link RaiseSpec} configured with type and status + */ public static RaiseSpec error(String errExpr, int status) { return new RaiseSpec().type(errExpr).status(status); } + /** + * Build a {@link RaiseSpec} for an error with a string type expression. + * + * @param errExpr error type expression (URI or logical id) + * @return a {@link RaiseSpec} configured with type only + */ public static RaiseSpec error(String errExpr) { return new RaiseSpec().type(errExpr); } + /** + * Build a {@link RaiseSpec} for an error using a concrete error type URI. + * + * @param errType error type URI + * @return a {@link RaiseSpec} configured with type only + */ public static RaiseSpec error(URI errType) { return new RaiseSpec().type(errType); } + /** + * Build a {@link RaiseSpec} for an error using a concrete error type URI and HTTP status. + * + * @param errType error type URI + * @param status HTTP status code + * @return a {@link RaiseSpec} configured with type and status + */ public static RaiseSpec error(URI errType, int status) { return new RaiseSpec().type(errType).status(status); } // --- Errors Recipes --- // + + /** + * Build a {@link RaiseSpec} based on a standard error enum. + * + * @param std standard error + * @return a {@link RaiseSpec} using the standard error URI and status + */ public static RaiseSpec error(Errors.Standard std) { return error(std.uri(), std.status()); } + /** + * Build a {@link RaiseSpec} based on a standard error enum with an overridden status. + * + * @param std standard error + * @param status HTTP status code + * @return a {@link RaiseSpec} using the standard URI and custom status + */ public static RaiseSpec error(Errors.Standard std, int status) { return error(std.uri(), status); } + /** + * Shortcut for a generic server runtime error according to {@link Errors#RUNTIME}. + * + * @return a {@link RaiseSpec} representing a runtime server error + */ public static RaiseSpec serverError() { return error(Errors.RUNTIME); } + /** + * Shortcut for a communication error according to {@link Errors#COMMUNICATION}. + * + * @return a {@link RaiseSpec} representing a communication error + */ public static RaiseSpec communicationError() { return error(Errors.COMMUNICATION); } + /** + * Shortcut for a "not implemented" error according to {@link Errors#NOT_IMPLEMENTED}. + * + * @return a {@link RaiseSpec} representing a not-implemented error + */ public static RaiseSpec notImplementedError() { return error(Errors.NOT_IMPLEMENTED); } + /** + * Shortcut for an unauthorized error according to {@link Errors#AUTHENTICATION}. + * + * @return a {@link RaiseSpec} representing an authentication error + */ public static RaiseSpec unauthorizedError() { return error(Errors.AUTHENTICATION); } + /** + * Shortcut for a forbidden error according to {@link Errors#AUTHORIZATION}. + * + * @return a {@link RaiseSpec} representing an authorization error + */ public static RaiseSpec forbiddenError() { return error(Errors.AUTHORIZATION); } + /** + * Shortcut for a timeout error according to {@link Errors#TIMEOUT}. + * + * @return a {@link RaiseSpec} representing a timeout error + */ public static RaiseSpec timeoutError() { return error(Errors.TIMEOUT); } + /** + * Shortcut for a data error according to {@link Errors#DATA}. + * + * @return a {@link RaiseSpec} representing a data error + */ public static RaiseSpec dataError() { return error(Errors.DATA); } // ---- Tasks ----// - public static TasksConfigurer call(CallHTTPConfigurer configurer) { - return list -> list.callHTTP(configurer); - } - + /** + * Create a {@link TasksConfigurer} that adds an HTTP call task using a low-level HTTP configurer. + * + *

Example: + * + *

{@code
+   * tasks(
+   *   call(http -> http.method("GET").endpoint("..."))
+   * );
+   * }
+ * + * @param configurer low-level HTTP configurer + * @return a {@link TasksConfigurer} that adds a CallHTTP task + */ + public static TasksConfigurer call(CallHttpConfigurer configurer) { + return list -> list.http(configurer); + } + + /** + * Create a {@link TasksConfigurer} that adds an OpenAPI call task. + * + *

Example: + * + *

{@code
+   * tasks(
+   *   call(openapi -> openapi.document("...").operation("getUser"))
+   * );
+   * }
+ * + * @param configurer OpenAPI configurer + * @return a {@link TasksConfigurer} that adds a CallOpenAPI task + */ + public static TasksConfigurer call(CallOpenAPIConfigurer configurer) { + return list -> list.openapi(configurer); + } + + /** + * Create a {@link TasksConfigurer} that adds a {@code set} task using a low-level configurer. + * + * @param configurer configurer for the set task + * @return a {@link TasksConfigurer} that adds a SetTask + */ public static TasksConfigurer set(SetConfigurer configurer) { return list -> list.set(configurer); } + /** + * Create a {@link TasksConfigurer} that adds a {@code set} task using a raw expression. + * + * @param expr expression to apply in the set task + * @return a {@link TasksConfigurer} that adds a SetTask + */ public static TasksConfigurer set(String expr) { return list -> list.set(expr); } + /** + * Create a {@link TasksConfigurer} that adds an {@code emit} task. + * + * @param configurer consumer configuring {@link EmitTaskBuilder} + * @return a {@link TasksConfigurer} that adds an EmitTask + */ public static TasksConfigurer emit(Consumer configurer) { return list -> list.emit(configurer); } + /** + * Create a {@link TasksConfigurer} that adds a {@code listen} task. + * + * @param configurer listen configurer + * @return a {@link TasksConfigurer} that adds a ListenTask + */ public static TasksConfigurer listen(ListenConfigurer configurer) { return list -> list.listen(configurer); } + /** + * Create a {@link TasksConfigurer} that adds a {@code forEach} task. + * + * @param configurer for-each configurer + * @return a {@link TasksConfigurer} that adds a ForEachTask + */ public static TasksConfigurer forEach(ForEachConfigurer configurer) { return list -> list.forEach(configurer); } + /** + * Create a {@link TasksConfigurer} that adds a {@code fork} task. + * + * @param configurer consumer configuring {@link ForkTaskBuilder} + * @return a {@link TasksConfigurer} that adds a ForkTask + */ public static TasksConfigurer fork(Consumer configurer) { return list -> list.fork(configurer); } + /** + * Create a {@link TasksConfigurer} that adds a {@code switch} task. + * + * @param configurer switch configurer + * @return a {@link TasksConfigurer} that adds a SwitchTask + */ public static TasksConfigurer switchCase(SwitchConfigurer configurer) { return list -> list.switchCase(configurer); } + /** + * Create a {@link TasksConfigurer} that adds a {@code raise} task. + * + * @param configurer raise configurer + * @return a {@link TasksConfigurer} that adds a RaiseTask + */ public static TasksConfigurer raise(RaiseConfigurer configurer) { return list -> list.raise(configurer); } + /** + * Create a {@link TasksConfigurer} that adds a {@code try/catch} task. + * + * @param configurer try/catch configurer + * @return a {@link TasksConfigurer} that adds a TryTask + */ public static TasksConfigurer tryCatch(TryConfigurer configurer) { return list -> list.tryCatch(configurer); } // ----- Tasks that requires tasks list --// - /** Main task list to be used in `workflow().tasks()` consumer. */ + /** + * Main task list adapter to be used in {@code workflow().tasks()} consumers. + * + *

This wraps multiple {@link TasksConfigurer} into a single {@link Consumer} of {@link + * DoTaskBuilder}. + * + * @param steps ordered list of task configurers + * @return a consumer configuring {@link DoTaskBuilder} with the provided steps + */ public static Consumer doTasks(TasksConfigurer... steps) { final Consumer tasks = tasks(steps); return d -> d.tasks(tasks); } - /** Task list for tasks that requires it such as `for`, `try`, and so on. */ + /** + * Task list for tasks that require a nested tasks list such as {@code for}, {@code try}, etc. + * + *

The configurers are applied in order and cloned defensively. + * + * @param steps ordered list of task configurers + * @return a {@link TasksConfigurer} applying all given steps + */ public static TasksConfigurer tasks(TasksConfigurer... steps) { Objects.requireNonNull(steps, "Steps in a tasks are required"); final List snapshot = List.of(steps.clone()); return list -> snapshot.forEach(s -> s.accept(list)); } + /** + * Build a {@link ForEachConfigurer} that uses a nested tasks list as its body. + * + * @param steps task configurers that make up the body of the for-each + * @return a {@link ForEachConfigurer} with the given tasks + */ public static ForEachConfigurer forEach(TasksConfigurer... steps) { final Consumer tasks = DSL.tasks(steps); return f -> f.tasks(tasks); } - /** Recipe for {@link io.serverlessworkflow.api.types.ForkTask} branch that DO compete */ + /** + * Recipe for {@link io.serverlessworkflow.api.types.ForkTask} where branches compete (first to + * complete "wins"). + * + *

Configures {@code compete(true)} and sets the branches tasks list. + * + * @param steps task configurers shared by all branches + * @return a {@link Consumer} configuring {@link ForkTaskBuilder} + */ public static Consumer branchesCompete(TasksConfigurer... steps) { final Consumer tasks = DSL.tasks(steps); return f -> f.compete(true).branches(tasks); } - /** Recipe for {@link io.serverlessworkflow.api.types.ForkTask} branch that DO NOT compete */ + /** + * Recipe for {@link io.serverlessworkflow.api.types.ForkTask} where branches do not compete (all + * branches are executed). + * + *

Configures {@code compete(false)} and sets the branches tasks list. + * + * @param steps task configurers shared by all branches + * @return a {@link Consumer} configuring {@link ForkTaskBuilder} + */ public static Consumer branches(TasksConfigurer... steps) { final Consumer tasks = DSL.tasks(steps); return f -> f.compete(false).branches(tasks); diff --git a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/dsl/UseSpec.java b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/dsl/UseSpec.java new file mode 100644 index 00000000..4f2b739b --- /dev/null +++ b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/dsl/UseSpec.java @@ -0,0 +1,47 @@ +/* + * 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.fluent.spec.dsl; + +import io.serverlessworkflow.fluent.spec.UseBuilder; +import io.serverlessworkflow.fluent.spec.configurers.AuthenticationConfigurer; +import io.serverlessworkflow.fluent.spec.configurers.UseConfigurer; +import java.util.LinkedList; +import java.util.List; + +public class UseSpec implements UseConfigurer { + + private final List steps = new LinkedList<>(); + + public UseSpec secrets(String... secrets) { + steps.add(u -> u.secrets(secrets)); + return this; + } + + public UseSpec secret(String secret) { + steps.add(u -> u.secrets(secret)); + return this; + } + + public UseSpec auth(String name, AuthenticationConfigurer auth) { + steps.add(u -> u.authentications(a -> a.authentication(name, auth))); + return this; + } + + @Override + public void accept(UseBuilder useBuilder) { + steps.forEach(step -> step.accept(useBuilder)); + } +} diff --git a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/spi/CallHTTPFluent.java b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/spi/CallHttpFluent.java similarity index 76% rename from fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/spi/CallHTTPFluent.java rename to fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/spi/CallHttpFluent.java index 48547057..11fd9e2a 100644 --- a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/spi/CallHTTPFluent.java +++ b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/spi/CallHttpFluent.java @@ -19,11 +19,11 @@ import java.util.UUID; import java.util.function.Consumer; -public interface CallHTTPFluent, LIST> { +public interface CallHttpFluent, LIST> { - LIST callHTTP(String name, Consumer itemsConfigurer); + LIST http(String name, Consumer itemsConfigurer); - default LIST callHTTP(Consumer itemsConfigurer) { - return this.callHTTP(UUID.randomUUID().toString(), itemsConfigurer); + default LIST http(Consumer itemsConfigurer) { + return this.http(UUID.randomUUID().toString(), itemsConfigurer); } } diff --git a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/spi/CallHttpTaskFluent.java b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/spi/CallHttpTaskFluent.java new file mode 100644 index 00000000..5df3123c --- /dev/null +++ b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/spi/CallHttpTaskFluent.java @@ -0,0 +1,205 @@ +/* + * 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.fluent.spec.spi; + +import io.serverlessworkflow.api.types.AuthenticationPolicyReference; +import io.serverlessworkflow.api.types.CallHTTP; +import io.serverlessworkflow.api.types.Endpoint; +import io.serverlessworkflow.api.types.EndpointConfiguration; +import io.serverlessworkflow.api.types.HTTPArguments; +import io.serverlessworkflow.api.types.HTTPHeaders; +import io.serverlessworkflow.api.types.HTTPQuery; +import io.serverlessworkflow.api.types.Headers; +import io.serverlessworkflow.api.types.Query; +import io.serverlessworkflow.api.types.ReferenceableAuthenticationPolicy; +import io.serverlessworkflow.api.types.UriTemplate; +import io.serverlessworkflow.fluent.spec.ReferenceableAuthenticationPolicyBuilder; +import io.serverlessworkflow.fluent.spec.TaskBaseBuilder; +import java.net.URI; +import java.util.Map; +import java.util.function.Consumer; + +public interface CallHttpTaskFluent> { + + default CallHTTP build() { + final CallHTTP callHTTP = ((CallHTTP) this.self().getTask()); + if (callHTTP.getWith().getOutput() == null) { + callHTTP.getWith().setOutput(HTTPArguments.HTTPOutput.CONTENT); + } + return callHTTP; + } + + SELF self(); + + default SELF method(String method) { + ((CallHTTP) this.self().getTask()).getWith().setMethod(method); + return self(); + } + + default SELF endpoint(URI endpoint) { + ((CallHTTP) this.self().getTask()) + .getWith() + .setEndpoint(new Endpoint().withUriTemplate(new UriTemplate().withLiteralUri(endpoint))); + return self(); + } + + default SELF endpoint(URI endpoint, Consumer auth) { + final ReferenceableAuthenticationPolicyBuilder policy = + new ReferenceableAuthenticationPolicyBuilder(); + auth.accept(policy); + ((CallHTTP) this.self().getTask()) + .getWith() + .setEndpoint( + new Endpoint() + .withEndpointConfiguration( + new EndpointConfiguration().withAuthentication(policy.build())) + .withUriTemplate(new UriTemplate().withLiteralUri(endpoint))); + return self(); + } + + default SELF endpoint(String expr) { + ((CallHTTP) this.self().getTask()).getWith().setEndpoint(EndpointUtil.fromString(expr)); + return self(); + } + + default SELF endpoint(String expr, Consumer auth) { + final ReferenceableAuthenticationPolicyBuilder policy = + new ReferenceableAuthenticationPolicyBuilder(); + auth.accept(policy); + + final Endpoint endpoint = EndpointUtil.fromString(expr); + endpoint.setEndpointConfiguration( + new EndpointConfiguration().withAuthentication(policy.build())); + + ((CallHTTP) this.self().getTask()).getWith().setEndpoint(endpoint); + return self(); + } + + default SELF endpoint(String expr, String authUse) { + final Endpoint endpoint = EndpointUtil.fromString(expr); + endpoint.withEndpointConfiguration( + new EndpointConfiguration() + .withAuthentication( + new ReferenceableAuthenticationPolicy() + .withAuthenticationPolicyReference( + new AuthenticationPolicyReference(authUse)))); + ((CallHTTP) this.self().getTask()).getWith().setEndpoint(endpoint); + return self(); + } + + default SELF headers(String expr) { + ((CallHTTP) this.self().getTask()) + .getWith() + .setHeaders(new Headers().withRuntimeExpression(expr)); + return self(); + } + + default SELF headers(Consumer consumer) { + HTTPHeadersBuilder hb = new HTTPHeadersBuilder(); + consumer.accept(hb); + CallHTTP httpTask = ((CallHTTP) this.self().getTask()); + if (httpTask.getWith().getHeaders() != null + && httpTask.getWith().getHeaders().getHTTPHeaders() != null) { + Headers h = httpTask.getWith().getHeaders(); + Headers built = hb.build(); + built + .getHTTPHeaders() + .getAdditionalProperties() + .forEach((k, v) -> h.getHTTPHeaders().setAdditionalProperty(k, v)); + } else { + httpTask.getWith().setHeaders(hb.build()); + } + + return self(); + } + + default SELF headers(Map headers) { + HTTPHeadersBuilder hb = new HTTPHeadersBuilder(); + hb.headers(headers); + ((CallHTTP) this.self().getTask()).getWith().setHeaders(hb.build()); + return self(); + } + + default SELF body(Object body) { + ((CallHTTP) this.self().getTask()).getWith().setBody(body); + return self(); + } + + default SELF query(String expr) { + ((CallHTTP) this.self().getTask()).getWith().setQuery(new Query().withRuntimeExpression(expr)); + return self(); + } + + default SELF query(Consumer consumer) { + HTTPQueryBuilder queryBuilder = new HTTPQueryBuilder(); + consumer.accept(queryBuilder); + ((CallHTTP) this.self().getTask()).getWith().setQuery(queryBuilder.build()); + return self(); + } + + default SELF query(Map query) { + HTTPQueryBuilder httpQueryBuilder = new HTTPQueryBuilder(); + httpQueryBuilder.queries(query); + ((CallHTTP) this.self().getTask()).getWith().setQuery(httpQueryBuilder.build()); + return self(); + } + + default SELF redirect(boolean redirect) { + ((CallHTTP) this.self().getTask()).getWith().setRedirect(redirect); + return self(); + } + + default SELF output(HTTPArguments.HTTPOutput output) { + ((CallHTTP) this.self().getTask()).getWith().setOutput(output); + return self(); + } + + class HTTPQueryBuilder { + private final HTTPQuery httpQuery = new HTTPQuery(); + + public HTTPQueryBuilder query(String name, String value) { + httpQuery.setAdditionalProperty(name, value); + return this; + } + + public HTTPQueryBuilder queries(Map headers) { + headers.forEach(httpQuery::setAdditionalProperty); + return this; + } + + public Query build() { + return new Query().withHTTPQuery(httpQuery); + } + } + + class HTTPHeadersBuilder { + private final HTTPHeaders httpHeaders = new HTTPHeaders(); + + public HTTPHeadersBuilder header(String name, String value) { + httpHeaders.setAdditionalProperty(name, value); + return this; + } + + public HTTPHeadersBuilder headers(Map headers) { + headers.forEach(httpHeaders::setAdditionalProperty); + return this; + } + + public Headers build() { + return new Headers().withHTTPHeaders(httpHeaders); + } + } +} diff --git a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/spi/CallOpenAPIFluent.java b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/spi/CallOpenAPIFluent.java new file mode 100644 index 00000000..cafa61af --- /dev/null +++ b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/spi/CallOpenAPIFluent.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.fluent.spec.spi; + +import io.serverlessworkflow.fluent.spec.TaskBaseBuilder; +import java.util.UUID; +import java.util.function.Consumer; + +public interface CallOpenAPIFluent, LIST> { + + LIST openapi(String name, Consumer itemsConfigurer); + + default LIST openapi(Consumer itemsConfigurer) { + return this.openapi(UUID.randomUUID().toString(), itemsConfigurer); + } +} diff --git a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/spi/CallOpenAPITaskFluent.java b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/spi/CallOpenAPITaskFluent.java new file mode 100644 index 00000000..48b1a701 --- /dev/null +++ b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/spi/CallOpenAPITaskFluent.java @@ -0,0 +1,143 @@ +/* + * 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.fluent.spec.spi; + +import io.serverlessworkflow.api.types.CallOpenAPI; +import io.serverlessworkflow.api.types.Endpoint; +import io.serverlessworkflow.api.types.EndpointConfiguration; +import io.serverlessworkflow.api.types.EndpointUri; +import io.serverlessworkflow.api.types.ExternalResource; +import io.serverlessworkflow.api.types.OpenAPIArguments; +import io.serverlessworkflow.api.types.UriTemplate; +import io.serverlessworkflow.fluent.spec.ReferenceableAuthenticationPolicyBuilder; +import io.serverlessworkflow.fluent.spec.TaskBaseBuilder; +import io.serverlessworkflow.fluent.spec.configurers.AuthenticationConfigurer; +import java.net.URI; +import java.util.Map; + +public interface CallOpenAPITaskFluent> { + + default CallOpenAPI build() { + final CallOpenAPI task = ((CallOpenAPI) this.self().getTask()); + if (task.getWith().getOutput() == null) { + task.getWith().setOutput(OpenAPIArguments.WithOpenAPIOutput.CONTENT); + } + return task; + } + + SELF self(); + + default SELF document(String uri) { + ((CallOpenAPI) this.self().getTask()) + .getWith() + .withDocument( + new ExternalResource().withEndpoint(new Endpoint().withRuntimeExpression(uri))); + return self(); + } + + default SELF document(URI uri) { + ((CallOpenAPI) this.self().getTask()) + .getWith() + .withDocument( + new ExternalResource() + .withEndpoint( + new Endpoint().withUriTemplate(new UriTemplate().withLiteralUri(uri)))); + return self(); + } + + default SELF document(String uri, AuthenticationConfigurer authenticationConfigurer) { + final ReferenceableAuthenticationPolicyBuilder policy = + new ReferenceableAuthenticationPolicyBuilder(); + authenticationConfigurer.accept(policy); + ((CallOpenAPI) this.self().getTask()).getWith().setAuthentication(policy.build()); + ((CallOpenAPI) this.self().getTask()) + .getWith() + .setDocument( + new ExternalResource() + .withEndpoint( + new Endpoint() + .withRuntimeExpression(uri) + .withEndpointConfiguration( + new EndpointConfiguration() + .withUri(new EndpointUri().withExpressionEndpointURI(uri)) + .withAuthentication(policy.build())))); + return self(); + } + + default SELF document(URI uri, AuthenticationConfigurer authenticationConfigurer) { + final ReferenceableAuthenticationPolicyBuilder policy = + new ReferenceableAuthenticationPolicyBuilder(); + authenticationConfigurer.accept(policy); + + ((CallOpenAPI) this.self().getTask()).getWith().setAuthentication(policy.build()); + ((CallOpenAPI) this.self().getTask()) + .getWith() + .setDocument( + new ExternalResource() + .withEndpoint( + new Endpoint() + .withUriTemplate(new UriTemplate().withLiteralUri(uri)) + .withEndpointConfiguration( + new EndpointConfiguration() + .withUri( + new EndpointUri() + .withLiteralEndpointURI( + new UriTemplate().withLiteralUri(uri))) + .withAuthentication(policy.build())))); + return self(); + } + + default SELF operation(String operation) { + ((CallOpenAPI) this.self().getTask()).getWith().setOperationId(operation); + return self(); + } + + default SELF parameters(Map parameters) { + ((CallOpenAPI) this.self().getTask()) + .getWith() + .getParameters() + .getAdditionalProperties() + .putAll(parameters); + return self(); + } + + default SELF parameter(String name, String value) { + ((CallOpenAPI) this.self().getTask()) + .getWith() + .getParameters() + .getAdditionalProperties() + .put(name, value); + return self(); + } + + default SELF authentication(AuthenticationConfigurer authenticationConfigurer) { + final ReferenceableAuthenticationPolicyBuilder policy = + new ReferenceableAuthenticationPolicyBuilder(); + authenticationConfigurer.accept(policy); + ((CallOpenAPI) this.self().getTask()).getWith().setAuthentication(policy.build()); + return self(); + } + + default SELF output(OpenAPIArguments.WithOpenAPIOutput output) { + ((CallOpenAPI) this.self().getTask()).getWith().setOutput(output); + return self(); + } + + default SELF redirect(boolean redirect) { + ((CallOpenAPI) this.self().getTask()).getWith().setRedirect(redirect); + return self(); + } +} diff --git a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/spi/DoFluent.java b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/spi/DoFluent.java index a18a08bf..c11c2d19 100644 --- a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/spi/DoFluent.java +++ b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/spi/DoFluent.java @@ -15,7 +15,8 @@ */ package io.serverlessworkflow.fluent.spec.spi; -import io.serverlessworkflow.fluent.spec.CallHTTPTaskBuilder; +import io.serverlessworkflow.fluent.spec.CallHttpTaskBuilder; +import io.serverlessworkflow.fluent.spec.CallOpenAPITaskBuilder; import io.serverlessworkflow.fluent.spec.EmitTaskBuilder; import io.serverlessworkflow.fluent.spec.ForEachTaskBuilder; import io.serverlessworkflow.fluent.spec.ForkTaskBuilder; @@ -37,9 +38,10 @@ public interface DoFluent extends SetFluent, SwitchFluent, TryCatchFluent, T>, - CallHTTPFluent, + CallHttpFluent, EmitFluent, ForEachFluent, T>, ForkFluent, ListenFluent, - RaiseFluent {} + RaiseFluent, + CallOpenAPIFluent {} diff --git a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/spi/EndpointUtil.java b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/spi/EndpointUtil.java new file mode 100644 index 00000000..d1b6a3ef --- /dev/null +++ b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/spi/EndpointUtil.java @@ -0,0 +1,66 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.serverlessworkflow.fluent.spec.spi; + +import io.serverlessworkflow.api.types.Endpoint; +import io.serverlessworkflow.api.types.UriTemplate; +import java.net.URI; +import java.util.Objects; + +public final class EndpointUtil { + + private EndpointUtil() {} + + public static Endpoint fromString(String expr) { + Objects.requireNonNull(expr, "Endpoint expression cannot be null"); + String trimmed = expr.trim(); + Endpoint endpoint = new Endpoint(); + if (isUrlLike(trimmed)) { + UriTemplate template = new UriTemplate(); + if (trimmed.indexOf('{') >= 0 || trimmed.indexOf('}') >= 0) { + template.setLiteralUriTemplate(trimmed); + } else { + template.setLiteralUri(URI.create(trimmed)); + } + endpoint.setUriTemplate(template); + return endpoint; + } + + // Let the runtime engine to verify if it's a valid jq expression since ${} it's not the only + // way of checking it. + endpoint.setRuntimeExpression(expr); + return endpoint; + } + + private static boolean isUrlLike(String value) { + // same idea as UriTemplate.literalUriTemplate_Pattern: ^[A-Za-z][A-Za-z0-9+\\-.]*://.* + int idx = value.indexOf("://"); + if (idx <= 0) { + return false; + } + char first = value.charAt(0); + if (!Character.isLetter(first)) { + return false; + } + for (int i = 1; i < idx; i++) { + char c = value.charAt(i); + if (!(Character.isLetterOrDigit(c) || c == '+' || c == '-' || c == '.')) { + return false; + } + } + return true; + } +} diff --git a/fluent/spec/src/test/java/io/serverlessworkflow/fluent/spec/WorkflowBuilderTest.java b/fluent/spec/src/test/java/io/serverlessworkflow/fluent/spec/WorkflowBuilderTest.java index 38c751eb..d62fd22a 100644 --- a/fluent/spec/src/test/java/io/serverlessworkflow/fluent/spec/WorkflowBuilderTest.java +++ b/fluent/spec/src/test/java/io/serverlessworkflow/fluent/spec/WorkflowBuilderTest.java @@ -407,7 +407,7 @@ void testDoTaskCallHTTPBasic() { WorkflowBuilder.workflow("flowCallBasic") .tasks( d -> - d.callHTTP( + d.http( "basicCall", http() .POST() @@ -433,7 +433,7 @@ void testDoTaskCallHTTPHeadersConsumerAndMap() { WorkflowBuilder.workflow("flowCallHeaders") .tasks( d -> - d.callHTTP( + d.http( "hdrCall", http().GET().endpoint("${uriExpr}").headers(Map.of("A", "1", "B", "2")))) .build(); @@ -446,7 +446,8 @@ void testDoTaskCallHTTPHeadersConsumerAndMap() { WorkflowBuilder.workflow() .tasks( d -> - d.callHTTP(http().GET().endpoint("expr").headers(Map.of("X", "10", "Y", "20")))) + d.http( + http().GET().endpoint("${ expr }").headers(Map.of("X", "10", "Y", "20")))) .build(); CallHTTP call2 = wf2.getDo().get(0).getTask().getCallTask().getCallHTTP(); HTTPHeaders hh2 = call2.getWith().getHeaders().getHTTPHeaders(); @@ -460,11 +461,11 @@ void testDoTaskCallHTTPQueryConsumerAndMap() { WorkflowBuilder.workflow("flowCallQuery") .tasks( d -> - d.callHTTP( + d.http( "qryCall", http() .GET() - .endpoint("exprUri") + .endpoint("${ exprUri }") .andThen(q -> q.query(Map.of("k1", "v1", "k2", "v2"))))) .build(); HTTPQuery hq = @@ -476,8 +477,11 @@ void testDoTaskCallHTTPQueryConsumerAndMap() { WorkflowBuilder.workflow() .tasks( d -> - d.callHTTP( - c -> c.method("GET").endpoint("uri").query(Map.of("q1", "x", "q2", "y")))) + d.http( + c -> + c.method("GET") + .endpoint("http://uri") + .query(Map.of("q1", "x", "q2", "y")))) .build(); HTTPQuery hq2 = wf2.getDo() @@ -498,11 +502,11 @@ void testDoTaskCallHTTPRedirectAndOutput() { WorkflowBuilder.workflow("flowCallOpts") .tasks( d -> - d.callHTTP( + d.http( "optCall", c -> c.method("DELETE") - .endpoint("expr") + .endpoint("${ expr }") .redirect(true) .output(HTTPArguments.HTTPOutput.RESPONSE))) .build(); diff --git a/fluent/spec/src/test/java/io/serverlessworkflow/fluent/spec/dsl/CallHttpAuthDslTest.java b/fluent/spec/src/test/java/io/serverlessworkflow/fluent/spec/dsl/CallHttpAuthDslTest.java index d9150b1c..874cce3b 100644 --- a/fluent/spec/src/test/java/io/serverlessworkflow/fluent/spec/dsl/CallHttpAuthDslTest.java +++ b/fluent/spec/src/test/java/io/serverlessworkflow/fluent/spec/dsl/CallHttpAuthDslTest.java @@ -17,6 +17,7 @@ import static io.serverlessworkflow.fluent.spec.dsl.DSL.basic; import static io.serverlessworkflow.fluent.spec.dsl.DSL.bearer; +import static io.serverlessworkflow.fluent.spec.dsl.DSL.call; import static io.serverlessworkflow.fluent.spec.dsl.DSL.digest; import static io.serverlessworkflow.fluent.spec.dsl.DSL.http; import static io.serverlessworkflow.fluent.spec.dsl.DSL.oauth2; @@ -37,11 +38,14 @@ public class CallHttpAuthDslTest { void when_call_http_with_basic_auth_on_endpoint_expr() { Workflow wf = WorkflowBuilder.workflow("f", "ns", "1") - .tasks(t -> t.callHTTP(http().GET().endpoint(EXPR_ENDPOINT, basic("alice", "secret")))) + .tasks(call(http().GET().endpoint(EXPR_ENDPOINT, basic("alice", "secret")))) .build(); var args = wf.getDo().get(0).getTask().getCallTask().getCallHTTP().getWith(); + assertThat(wf.getDo().get(0).getTask().get()).isNotNull(); + assertThat(wf.getDo().get(0).getTask().getCallTask().get()).isNotNull(); + // Endpoint expression is set assertThat(args.getEndpoint().getRuntimeExpression()).isEqualTo(EXPR_ENDPOINT); @@ -74,7 +78,7 @@ void when_call_http_with_basic_auth_on_endpoint_expr() { void when_call_http_with_bearer_auth_on_endpoint_expr() { Workflow wf = WorkflowBuilder.workflow("f", "ns", "1") - .tasks(t -> t.callHTTP(http().GET().endpoint(EXPR_ENDPOINT, bearer("token-123")))) + .tasks(call(http().GET().endpoint(EXPR_ENDPOINT, bearer("token-123")))) .build(); var args = wf.getDo().get(0).getTask().getCallTask().getCallHTTP().getWith(); @@ -103,7 +107,7 @@ void when_call_http_with_bearer_auth_on_endpoint_expr() { void when_call_http_with_digest_auth_on_endpoint_expr() { Workflow wf = WorkflowBuilder.workflow("f", "ns", "1") - .tasks(t -> t.callHTTP(http().GET().endpoint(EXPR_ENDPOINT, digest("bob", "p@ssw0rd")))) + .tasks(call(http().GET().endpoint(EXPR_ENDPOINT, digest("bob", "p@ssw0rd")))) .build(); var args = wf.getDo().get(0).getTask().getCallTask().getCallHTTP().getWith(); @@ -139,18 +143,17 @@ void when_call_http_with_oidc_auth_on_endpoint_expr_with_client() { Workflow wf = WorkflowBuilder.workflow("f", "ns", "1") .tasks( - t -> - t.callHTTP( - http() - .POST() - .endpoint( - EXPR_ENDPOINT, - oidc( - "https://auth.example.com/", - OAuth2AuthenticationData.OAuth2AuthenticationDataGrant - .CLIENT_CREDENTIALS, - "client-id", - "client-secret")))) + call( + http() + .POST() + .endpoint( + EXPR_ENDPOINT, + oidc( + "https://auth.example.com/", + OAuth2AuthenticationData.OAuth2AuthenticationDataGrant + .CLIENT_CREDENTIALS, + "client-id", + "client-secret")))) .build(); var args = wf.getDo().get(0).getTask().getCallTask().getCallHTTP().getWith(); @@ -187,16 +190,15 @@ void when_call_http_with_oauth2_alias_on_endpoint_expr_without_client() { Workflow wf = WorkflowBuilder.workflow("f", "ns", "1") .tasks( - t -> - t.callHTTP( - http() - .POST() - .endpoint( - EXPR_ENDPOINT, - oauth2( - "https://auth.example.com/", - OAuth2AuthenticationData.OAuth2AuthenticationDataGrant - .CLIENT_CREDENTIALS)))) + call( + http() + .POST() + .endpoint( + EXPR_ENDPOINT, + oauth2( + "https://auth.example.com/", + OAuth2AuthenticationData.OAuth2AuthenticationDataGrant + .CLIENT_CREDENTIALS)))) .build(); var args = wf.getDo().get(0).getTask().getCallTask().getCallHTTP().getWith(); @@ -230,10 +232,7 @@ void when_call_http_with_oauth2_alias_on_endpoint_expr_without_client() { void when_call_http_with_basic_auth_on_uri_string() { Workflow wf = WorkflowBuilder.workflow("f", "ns", "1") - .tasks( - t -> - t.callHTTP( - http().GET().uri("https://api.example.com/v1/resource", basic("u", "p")))) + .tasks(call(http().GET().uri("https://api.example.com/v1/resource", basic("u", "p")))) .build(); var args = wf.getDo().get(0).getTask().getCallTask().getCallHTTP().getWith(); diff --git a/fluent/spec/src/test/java/io/serverlessworkflow/fluent/spec/dsl/CallOpenApiDslTest.java b/fluent/spec/src/test/java/io/serverlessworkflow/fluent/spec/dsl/CallOpenApiDslTest.java new file mode 100644 index 00000000..70a91f28 --- /dev/null +++ b/fluent/spec/src/test/java/io/serverlessworkflow/fluent/spec/dsl/CallOpenApiDslTest.java @@ -0,0 +1,95 @@ +/* + * 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.fluent.spec.dsl; + +import static io.serverlessworkflow.fluent.spec.dsl.DSL.basic; +import static io.serverlessworkflow.fluent.spec.dsl.DSL.call; +import static io.serverlessworkflow.fluent.spec.dsl.DSL.openapi; +import static org.assertj.core.api.Assertions.assertThat; + +import io.serverlessworkflow.api.types.OpenAPIArguments; +import io.serverlessworkflow.api.types.Workflow; +import io.serverlessworkflow.fluent.spec.WorkflowBuilder; +import java.util.Map; +import org.junit.jupiter.api.Test; + +public class CallOpenApiDslTest { + + private static final String EXPR_DOCUMENT = "${ \"https://api.example.com/v1/openapi.yaml\" }"; + + @Test + void when_call_openapi_with_basic_auth_on_document_expr() { + Workflow wf = + WorkflowBuilder.workflow("f", "ns", "1") + .tasks( + call( + openapi() + .document(EXPR_DOCUMENT, basic("alice", "secret")) + .operation("getPetById") + .parameter("id", "123"))) + .build(); + + var taskItem = wf.getDo().get(0); + var callOpenAPI = taskItem.getTask().getCallTask().getCallOpenAPI(); + assertThat(callOpenAPI).isNotNull(); + + var with = callOpenAPI.getWith(); + assertThat(with).isNotNull(); + + // Default output is CONTENT if not explicitly set + assertThat(with.getOutput()).isEqualTo(OpenAPIArguments.WithOpenAPIOutput.CONTENT); + + // Document and endpoint expression + assertThat(with.getDocument()).isNotNull(); + assertThat(with.getDocument().getEndpoint()).isNotNull(); + assertThat(with.getDocument().getEndpoint().getRuntimeExpression()).isEqualTo(EXPR_DOCUMENT); + + // Endpoint configuration URI expression + var endpointConfig = with.getDocument().getEndpoint().getEndpointConfiguration(); + assertThat(endpointConfig).isNotNull(); + assertThat(endpointConfig.getUri()).isNotNull(); + assertThat(endpointConfig.getUri().getExpressionEndpointURI()).isEqualTo(EXPR_DOCUMENT); + + // Parameters wired through DSL + assertThat(with.getParameters()).isNotNull(); + Map params = with.getParameters().getAdditionalProperties(); + assertThat(params).containsEntry("id", "123"); + + // Authentication attached to endpoint configuration + var endpointAuth = endpointConfig.getAuthentication().getAuthenticationPolicy(); + assertThat(endpointAuth).isNotNull(); + assertThat(endpointAuth.getBasicAuthenticationPolicy()).isNotNull(); + assertThat( + endpointAuth + .getBasicAuthenticationPolicy() + .getBasic() + .getBasicAuthenticationProperties() + .getUsername()) + .isEqualTo("alice"); + assertThat( + endpointAuth + .getBasicAuthenticationPolicy() + .getBasic() + .getBasicAuthenticationProperties() + .getPassword()) + .isEqualTo("secret"); + + // Authentication also attached at the top-level "with.authentication" + var topLevelAuth = with.getAuthentication().getAuthenticationPolicy(); + assertThat(topLevelAuth).isNotNull(); + assertThat(topLevelAuth.getBasicAuthenticationPolicy()).isNotNull(); + } +} diff --git a/fluent/spec/src/test/java/io/serverlessworkflow/fluent/spec/dsl/DSLTest.java b/fluent/spec/src/test/java/io/serverlessworkflow/fluent/spec/dsl/DSLTest.java index 894b9a6b..80b9d1e1 100644 --- a/fluent/spec/src/test/java/io/serverlessworkflow/fluent/spec/dsl/DSLTest.java +++ b/fluent/spec/src/test/java/io/serverlessworkflow/fluent/spec/dsl/DSLTest.java @@ -15,9 +15,14 @@ */ package io.serverlessworkflow.fluent.spec.dsl; +import static io.serverlessworkflow.fluent.spec.dsl.DSL.auth; +import static io.serverlessworkflow.fluent.spec.dsl.DSL.basic; +import static io.serverlessworkflow.fluent.spec.dsl.DSL.bearer; +import static io.serverlessworkflow.fluent.spec.dsl.DSL.call; import static io.serverlessworkflow.fluent.spec.dsl.DSL.error; import static io.serverlessworkflow.fluent.spec.dsl.DSL.event; import static io.serverlessworkflow.fluent.spec.dsl.DSL.http; +import static io.serverlessworkflow.fluent.spec.dsl.DSL.secrets; import static io.serverlessworkflow.fluent.spec.dsl.DSL.to; import static org.assertj.core.api.Assertions.assertThat; @@ -32,17 +37,16 @@ public class DSLTest { @Test - public void when_new_call_http_task() { + public void when_new_http_call_task() { Workflow wf = WorkflowBuilder.workflow("myFlow", "myNs", "1.2.3") .tasks( - t -> - t.callHTTP( - http() - .acceptJSON() - .header("CustomKey", "CustomValue") - .POST() - .endpoint("${ \"https://petstore.swagger.io/v2/pet/\\(.petId)\" }"))) + call( + http() + .acceptJSON() + .header("CustomKey", "CustomValue") + .POST() + .endpoint("${ \"https://petstore.swagger.io/v2/pet/\\(.petId)\" }"))) .build(); HTTPArguments args = wf.getDo().get(0).getTask().getCallTask().getCallHTTP().getWith(); @@ -260,4 +264,21 @@ void serverError_uses_versioned_uri_and_default_code() { assertThat(def.getTitle().getExpressionErrorTitle()).isEqualTo("Boom"); assertThat(def.getDetail().getExpressionErrorDetails()).isEqualTo("x"); } + + @Test + void use_spec_accumulates_secrets_and_auths() { + Workflow wf = + WorkflowBuilder.workflow("id", "ns", "1") + .use( + auth("basic-auth", basic("u", "p")), + auth("bearer-auth", bearer("t")), + secrets("s1", "s2")) + .build(); + + var use = wf.getUse(); + assertThat(use).isNotNull(); + assertThat(use.getSecrets()).containsExactly("s1", "s2"); + assertThat(use.getAuthentications().getAdditionalProperties().keySet()) + .containsExactly("basic-auth", "bearer-auth"); + } } From 9a163843e523b41314e733d97170303929efc9ef Mon Sep 17 00:00:00 2001 From: Francisco Javier Tirado Sarti <65240126+fjtirado@users.noreply.github.com> Date: Mon, 17 Nov 2025 15:58:14 +0100 Subject: [PATCH 13/13] [Fix #952] Refactoring auth code (#975) * [Fix #952] Refactoring auth code Signed-off-by: fjtirado * [Fix #952] More refactor Signed-off-by: fjtirado --------- Signed-off-by: fjtirado --- .../impl/WorkflowApplication.java | 9 ++ .../impl/WorkflowUtils.java | 43 +++++ .../impl/config/ConfigSecretManager.java | 8 +- .../impl/config/SecretManager.java | 2 +- .../impl/resources/DefaultResourceLoader.java | 37 +---- .../executors/http/AbstractAuthProvider.java | 17 +- .../executors/http/BasicAuthProvider.java | 9 +- .../executors/http/BearerAuthProvider.java | 6 +- .../executors/http/CommonOAuthProvider.java | 67 ++++++++ .../impl/executors/http/HttpExecutor.java | 11 +- .../executors/http/OAuth2AuthProvider.java | 32 +--- .../executors/http/OpenIdAuthProvider.java | 34 +--- .../impl/executors/http/SecretKeys.java | 37 +++++ .../AbstractAuthRequestBuilder.java | 153 +++++++++++------- .../requestbuilder/AccessTokenProvider.java | 115 +++++++++++-- .../AccessTokenProviderFactory.java | 55 +++++++ .../requestbuilder/AuthRequestBuilder.java | 11 +- .../requestbuilder/ClientSecretBasic.java | 101 ++++++------ .../requestbuilder/ClientSecretHandler.java | 86 ++++++++++ .../auth/requestbuilder/ClientSecretPost.java | 93 ++++++----- .../requestbuilder/HttpRequestBuilder.java | 137 ---------------- ...cationHolder.java => HttpRequestInfo.java} | 34 ++-- .../HttpRequestInfoBuilder.java | 96 +++++++++++ .../requestbuilder/OAuthRequestBuilder.java | 55 ++++--- .../requestbuilder/OpenIdRequestBuilder.java | 27 ++-- .../requestbuilder/TokenResponseHandler.java | 64 -------- .../impl/expressions/jq/JQExpression.java | 2 +- ...ntSecretPostPasswordAllGrantsHttpCall.yaml | 2 +- 28 files changed, 794 insertions(+), 549 deletions(-) create mode 100644 impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/CommonOAuthProvider.java create mode 100644 impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/SecretKeys.java create mode 100644 impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/AccessTokenProviderFactory.java create mode 100644 impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/ClientSecretHandler.java delete mode 100644 impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/HttpRequestBuilder.java rename impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/{InvocationHolder.java => HttpRequestInfo.java} (55%) create mode 100644 impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/HttpRequestInfoBuilder.java delete mode 100644 impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/TokenResponseHandler.java diff --git a/impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowApplication.java b/impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowApplication.java index 413bfbe8..2cbca028 100644 --- a/impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowApplication.java +++ b/impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowApplication.java @@ -35,6 +35,7 @@ import io.serverlessworkflow.impl.resources.DefaultResourceLoaderFactory; import io.serverlessworkflow.impl.resources.ExternalResourceHandler; import io.serverlessworkflow.impl.resources.ResourceLoaderFactory; +import io.serverlessworkflow.impl.resources.URITemplateResolver; import io.serverlessworkflow.impl.scheduler.DefaultWorkflowScheduler; import io.serverlessworkflow.impl.scheduler.WorkflowScheduler; import io.serverlessworkflow.impl.schema.SchemaValidator; @@ -73,6 +74,7 @@ public class WorkflowApplication implements AutoCloseable { private final ConfigManager configManager; private final SecretManager secretManager; private final SchedulerListener schedulerListener; + private final Optional templateResolver; private WorkflowApplication(Builder builder) { this.taskFactory = builder.taskFactory; @@ -94,6 +96,7 @@ private WorkflowApplication(Builder builder) { this.additionalObjects = builder.additionalObjects; this.configManager = builder.configManager; this.secretManager = builder.secretManager; + this.templateResolver = builder.templateResolver; } public TaskExecutorFactory taskFactory() { @@ -173,6 +176,7 @@ public SchemaValidator getValidator(SchemaInline inline) { private SecretManager secretManager; private ConfigManager configManager; private SchedulerListener schedulerListener; + private Optional templateResolver; private Builder() {} @@ -325,6 +329,7 @@ public WorkflowApplication build() { .findFirst() .orElseGet(() -> new ConfigSecretManager(configManager)); } + templateResolver = ServiceLoader.load(URITemplateResolver.class).findFirst(); return new WorkflowApplication(this); } } @@ -398,6 +403,10 @@ SchedulerListener schedulerListener() { return schedulerListener; } + public Optional templateResolver() { + return templateResolver; + } + public Optional additionalObject( String name, WorkflowContext workflowContext, TaskContext taskContext) { return Optional.ofNullable(additionalObjects.get(name)) diff --git a/impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowUtils.java b/impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowUtils.java index 6fef5a85..4ebfcc8e 100644 --- a/impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowUtils.java +++ b/impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowUtils.java @@ -20,8 +20,10 @@ import io.serverlessworkflow.api.types.InputFrom; import io.serverlessworkflow.api.types.OutputAs; import io.serverlessworkflow.api.types.SchemaUnion; +import io.serverlessworkflow.api.types.SecretBasedAuthenticationPolicy; import io.serverlessworkflow.api.types.TimeoutAfter; import io.serverlessworkflow.api.types.UriTemplate; +import io.serverlessworkflow.api.types.Workflow; import io.serverlessworkflow.impl.expressions.ExpressionDescriptor; import io.serverlessworkflow.impl.expressions.ExpressionUtils; import io.serverlessworkflow.impl.resources.ResourceLoader; @@ -200,4 +202,45 @@ public static WorkflowValueResolver fromTimeoutAfter( return (w, t, f) -> Duration.ZERO; } } + + public static final String secretProp(WorkflowContext context, String secretName, String prop) { + return (String) secret(context, secretName).get(prop); + } + + public static final Map secret(WorkflowContext context, String secretName) { + return context.definition().application().secretManager().secret(secretName); + } + + public static final String checkSecret( + Workflow workflow, SecretBasedAuthenticationPolicy secretPolicy) { + String secretName = secretPolicy.getUse(); + return workflow.getUse().getSecrets().stream() + .filter(s -> s.equals(secretName)) + .findAny() + .orElseThrow(() -> new IllegalStateException("Secret " + secretName + " does not exist")); + } + + public static URI concatURI(URI uri, String pathToAppend) { + return uri.getPath().endsWith("/") + ? uri.resolve(pathToAppend) + : URI.create( + uri.toString() + (pathToAppend.startsWith("/") ? pathToAppend : "/" + pathToAppend)); + } + + public static WorkflowValueResolver getURISupplier( + WorkflowApplication application, UriTemplate template) { + if (template.getLiteralUri() != null) { + return (w, t, n) -> template.getLiteralUri(); + } else if (template.getLiteralUriTemplate() != null) { + return (w, t, n) -> + application + .templateResolver() + .orElseThrow( + () -> + new IllegalStateException( + "Need an uri template resolver to resolve uri template")) + .resolveTemplates(template.getLiteralUriTemplate(), w, t, n); + } + throw new IllegalArgumentException("Invalid uritemplate definition " + template); + } } diff --git a/impl/core/src/main/java/io/serverlessworkflow/impl/config/ConfigSecretManager.java b/impl/core/src/main/java/io/serverlessworkflow/impl/config/ConfigSecretManager.java index a720e447..b9e1b6ae 100644 --- a/impl/core/src/main/java/io/serverlessworkflow/impl/config/ConfigSecretManager.java +++ b/impl/core/src/main/java/io/serverlessworkflow/impl/config/ConfigSecretManager.java @@ -23,19 +23,19 @@ public class ConfigSecretManager implements SecretManager { private final ConfigManager configManager; - private Map> secretMap = new ConcurrentHashMap<>(); + private Map> secretMap = new ConcurrentHashMap<>(); public ConfigSecretManager(ConfigManager configManager) { this.configManager = configManager; } @Override - public Map secret(String secretName) { + public Map secret(String secretName) { return secretMap.computeIfAbsent(secretName, this::buildMap); } - private Map buildMap(String secretName) { - Map map = new HashMap(); + private Map buildMap(String secretName) { + Map map = new HashMap<>(); final String prefix = secretName + "."; for (String name : configManager.names()) { if (name.startsWith(prefix)) { diff --git a/impl/core/src/main/java/io/serverlessworkflow/impl/config/SecretManager.java b/impl/core/src/main/java/io/serverlessworkflow/impl/config/SecretManager.java index aeeabaaf..4f639eac 100644 --- a/impl/core/src/main/java/io/serverlessworkflow/impl/config/SecretManager.java +++ b/impl/core/src/main/java/io/serverlessworkflow/impl/config/SecretManager.java @@ -20,5 +20,5 @@ @FunctionalInterface public interface SecretManager extends ServicePriority { - Map secret(String secretName); + Map secret(String secretName); } diff --git a/impl/core/src/main/java/io/serverlessworkflow/impl/resources/DefaultResourceLoader.java b/impl/core/src/main/java/io/serverlessworkflow/impl/resources/DefaultResourceLoader.java index 47c6a1c9..e3018717 100644 --- a/impl/core/src/main/java/io/serverlessworkflow/impl/resources/DefaultResourceLoader.java +++ b/impl/core/src/main/java/io/serverlessworkflow/impl/resources/DefaultResourceLoader.java @@ -15,10 +15,11 @@ */ package io.serverlessworkflow.impl.resources; +import static io.serverlessworkflow.impl.WorkflowUtils.getURISupplier; + import io.serverlessworkflow.api.types.Endpoint; import io.serverlessworkflow.api.types.EndpointUri; import io.serverlessworkflow.api.types.ExternalResource; -import io.serverlessworkflow.api.types.UriTemplate; import io.serverlessworkflow.impl.TaskContext; import io.serverlessworkflow.impl.WorkflowApplication; import io.serverlessworkflow.impl.WorkflowContext; @@ -31,9 +32,7 @@ import java.time.Instant; import java.util.Map; import java.util.Optional; -import java.util.ServiceLoader; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicReference; import java.util.function.Function; public class DefaultResourceLoader implements ResourceLoader { @@ -41,9 +40,6 @@ public class DefaultResourceLoader implements ResourceLoader { private final Optional workflowPath; private final WorkflowApplication application; - private final AtomicReference templateResolver = - new AtomicReference(); - private Map resourceCache = new ConcurrentHashMap<>(); protected DefaultResourceLoader(WorkflowApplication application, Path workflowPath) { @@ -51,21 +47,6 @@ protected DefaultResourceLoader(WorkflowApplication application, Path workflowPa this.workflowPath = Optional.ofNullable(workflowPath); } - private URITemplateResolver templateResolver() { - URITemplateResolver result = templateResolver.get(); - if (result == null) { - result = - ServiceLoader.load(URITemplateResolver.class) - .findFirst() - .orElseThrow( - () -> - new IllegalStateException( - "Need an uri template resolver to resolve uri template")); - templateResolver.set(result); - } - return result; - } - private ExternalResourceHandler fileResource(String pathStr) { Path path = Path.of(pathStr); if (path.isAbsolute()) { @@ -122,7 +103,7 @@ public WorkflowValueResolver uriSupplier(Endpoint endpoint) { if (endpoint.getEndpointConfiguration() != null) { EndpointUri uri = endpoint.getEndpointConfiguration().getUri(); if (uri.getLiteralEndpointURI() != null) { - return getURISupplier(uri.getLiteralEndpointURI()); + return getURISupplier(application, uri.getLiteralEndpointURI()); } else if (uri.getExpressionEndpointURI() != null) { return new ExpressionURISupplier( application @@ -135,21 +116,11 @@ public WorkflowValueResolver uriSupplier(Endpoint endpoint) { .expressionFactory() .resolveString(ExpressionDescriptor.from(endpoint.getRuntimeExpression()))); } else if (endpoint.getUriTemplate() != null) { - return getURISupplier(endpoint.getUriTemplate()); + return getURISupplier(application, endpoint.getUriTemplate()); } throw new IllegalArgumentException("Invalid endpoint definition " + endpoint); } - private WorkflowValueResolver getURISupplier(UriTemplate template) { - if (template.getLiteralUri() != null) { - return (w, t, n) -> template.getLiteralUri(); - } else if (template.getLiteralUriTemplate() != null) { - return (w, t, n) -> - templateResolver().resolveTemplates(template.getLiteralUriTemplate(), w, t, n); - } - throw new IllegalArgumentException("Invalid uritemplate definition " + template); - } - private class ExpressionURISupplier implements WorkflowValueResolver { private WorkflowValueResolver expr; diff --git a/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/AbstractAuthProvider.java b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/AbstractAuthProvider.java index e75961de..ab5eb7ac 100644 --- a/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/AbstractAuthProvider.java +++ b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/AbstractAuthProvider.java @@ -15,14 +15,12 @@ */ package io.serverlessworkflow.impl.executors.http; -import io.serverlessworkflow.api.types.SecretBasedAuthenticationPolicy; -import io.serverlessworkflow.api.types.Workflow; import io.serverlessworkflow.impl.TaskContext; import io.serverlessworkflow.impl.WorkflowContext; import io.serverlessworkflow.impl.WorkflowModel; import jakarta.ws.rs.client.Invocation.Builder; -public abstract class AbstractAuthProvider implements AuthProvider { +abstract class AbstractAuthProvider implements AuthProvider { private static final String AUTH_HEADER_FORMAT = "%s %s"; @@ -37,19 +35,6 @@ public Builder build( return builder; } - protected final String checkSecret( - Workflow workflow, SecretBasedAuthenticationPolicy secretPolicy) { - String secretName = secretPolicy.getUse(); - return workflow.getUse().getSecrets().stream() - .filter(s -> s.equals(secretName)) - .findAny() - .orElseThrow(() -> new IllegalStateException("Secret " + secretName + " does not exist")); - } - - protected final String find(WorkflowContext context, String secretName, String prop) { - return context.definition().application().secretManager().secret(secretName).get(prop); - } - protected abstract String authScheme(); protected abstract String authParameter( diff --git a/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/BasicAuthProvider.java b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/BasicAuthProvider.java index 1c824e06..f390d4b2 100644 --- a/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/BasicAuthProvider.java +++ b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/BasicAuthProvider.java @@ -15,6 +15,11 @@ */ package io.serverlessworkflow.impl.executors.http; +import static io.serverlessworkflow.impl.WorkflowUtils.checkSecret; +import static io.serverlessworkflow.impl.WorkflowUtils.secretProp; +import static io.serverlessworkflow.impl.executors.http.SecretKeys.PASSWORD; +import static io.serverlessworkflow.impl.executors.http.SecretKeys.USER; + import io.serverlessworkflow.api.types.BasicAuthenticationPolicy; import io.serverlessworkflow.api.types.Workflow; import io.serverlessworkflow.impl.TaskContext; @@ -44,8 +49,8 @@ public BasicAuthProvider( } else if (authPolicy.getBasic().getBasicAuthenticationPolicySecret() != null) { String secretName = checkSecret(workflow, authPolicy.getBasic().getBasicAuthenticationPolicySecret()); - userFilter = (w, t, m) -> find(w, secretName, "username"); - passwordFilter = (w, t, m) -> find(w, secretName, "password"); + userFilter = (w, t, m) -> secretProp(w, secretName, USER); + passwordFilter = (w, t, m) -> secretProp(w, secretName, PASSWORD); } else { throw new IllegalStateException("Both secret and properties are null for authorization"); } diff --git a/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/BearerAuthProvider.java b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/BearerAuthProvider.java index 58b673e9..e1a6bb20 100644 --- a/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/BearerAuthProvider.java +++ b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/BearerAuthProvider.java @@ -15,6 +15,10 @@ */ package io.serverlessworkflow.impl.executors.http; +import static io.serverlessworkflow.impl.WorkflowUtils.checkSecret; +import static io.serverlessworkflow.impl.WorkflowUtils.secretProp; +import static io.serverlessworkflow.impl.executors.http.SecretKeys.TOKEN; + import io.serverlessworkflow.api.types.BearerAuthenticationPolicy; import io.serverlessworkflow.api.types.BearerAuthenticationPolicyConfiguration; import io.serverlessworkflow.api.types.Workflow; @@ -39,7 +43,7 @@ public BearerAuthProvider( tokenFilter = WorkflowUtils.buildStringFilter(app, token); } else if (config.getBearerAuthenticationPolicySecret() != null) { String secretName = checkSecret(workflow, config.getBearerAuthenticationPolicySecret()); - tokenFilter = (w, t, m) -> find(w, secretName, "bearer"); + tokenFilter = (w, t, m) -> secretProp(w, secretName, TOKEN); } } diff --git a/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/CommonOAuthProvider.java b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/CommonOAuthProvider.java new file mode 100644 index 00000000..6280c5f6 --- /dev/null +++ b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/CommonOAuthProvider.java @@ -0,0 +1,67 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.serverlessworkflow.impl.executors.http; + +import static io.serverlessworkflow.impl.WorkflowUtils.checkSecret; + +import io.serverlessworkflow.api.types.OAuth2AuthenticationData; +import io.serverlessworkflow.api.types.SecretBasedAuthenticationPolicy; +import io.serverlessworkflow.api.types.Workflow; +import io.serverlessworkflow.impl.TaskContext; +import io.serverlessworkflow.impl.WorkflowContext; +import io.serverlessworkflow.impl.WorkflowModel; +import io.serverlessworkflow.impl.WorkflowValueResolver; +import io.serverlessworkflow.impl.executors.http.auth.requestbuilder.AccessTokenProvider; +import io.serverlessworkflow.impl.executors.http.auth.requestbuilder.AccessTokenProviderFactory; +import io.serverlessworkflow.impl.executors.http.auth.requestbuilder.AuthRequestBuilder; +import java.util.Map; + +abstract class CommonOAuthProvider extends AbstractAuthProvider { + + private final WorkflowValueResolver tokenProvider; + + protected CommonOAuthProvider(WorkflowValueResolver tokenProvider) { + this.tokenProvider = tokenProvider; + } + + @Override + protected String authParameter(WorkflowContext workflow, TaskContext task, WorkflowModel model) { + return tokenProvider.apply(workflow, task, model).validateAndGet(workflow, task, model).token(); + } + + @Override + protected String authScheme() { + return "Bearer"; + } + + protected static OAuth2AuthenticationData fillFromMap( + OAuth2AuthenticationData data, Map secretMap) { + return data; + } + + protected static WorkflowValueResolver accessToken( + Workflow workflow, + OAuth2AuthenticationData authenticationData, + SecretBasedAuthenticationPolicy secret, + AuthRequestBuilder builder) { + if (authenticationData != null) { + return AccessTokenProviderFactory.build(authenticationData, builder); + } else if (secret != null) { + return AccessTokenProviderFactory.build(checkSecret(workflow, secret), builder); + } + throw new IllegalStateException("Both policy and secret are null"); + } +} diff --git a/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/HttpExecutor.java b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/HttpExecutor.java index 1bd6428f..420931a4 100644 --- a/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/HttpExecutor.java +++ b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/HttpExecutor.java @@ -47,7 +47,7 @@ public class HttpExecutor implements CallableTask { // TODO allow changing default converter private static final HttpModelConverter defaultConverter = new HttpModelConverter() {}; - private TargetSupplier targetSupplier; + private WorkflowValueResolver targetSupplier; private Optional>> headersMap; private Optional>> queryMap; private Optional authProvider; @@ -255,18 +255,15 @@ public boolean accept(Class clazz) { return clazz.equals(CallHTTP.class); } - private static TargetSupplier getTargetSupplier(WorkflowValueResolver uriSupplier) { + private static WorkflowValueResolver getTargetSupplier( + WorkflowValueResolver uriSupplier) { return (w, t, n) -> HttpClientResolver.client(w, t).target(uriSupplier.apply(w, t, n)); } - private static TargetSupplier getTargetSupplier( + private static WorkflowValueResolver getTargetSupplier( WorkflowValueResolver uriSupplier, WorkflowValueResolver pathSupplier) { return (w, t, n) -> HttpClientResolver.client(w, t) .target(uriSupplier.apply(w, t, n).resolve(pathSupplier.apply(w, t, n))); } - - private static interface TargetSupplier { - WebTarget apply(WorkflowContext workflow, TaskContext task, WorkflowModel node); - } } diff --git a/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/OAuth2AuthProvider.java b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/OAuth2AuthProvider.java index c36d17eb..b3de11de 100644 --- a/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/OAuth2AuthProvider.java +++ b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/OAuth2AuthProvider.java @@ -16,37 +16,19 @@ package io.serverlessworkflow.impl.executors.http; import io.serverlessworkflow.api.types.OAuth2AuthenticationPolicy; -import io.serverlessworkflow.api.types.OAuth2AuthenticationPolicyConfiguration; import io.serverlessworkflow.api.types.Workflow; -import io.serverlessworkflow.impl.TaskContext; import io.serverlessworkflow.impl.WorkflowApplication; -import io.serverlessworkflow.impl.WorkflowContext; -import io.serverlessworkflow.impl.WorkflowModel; -import io.serverlessworkflow.impl.executors.http.auth.requestbuilder.AuthRequestBuilder; import io.serverlessworkflow.impl.executors.http.auth.requestbuilder.OAuthRequestBuilder; -public class OAuth2AuthProvider extends AbstractAuthProvider { - - private AuthRequestBuilder requestBuilder; +class OAuth2AuthProvider extends CommonOAuthProvider { public OAuth2AuthProvider( WorkflowApplication application, Workflow workflow, OAuth2AuthenticationPolicy authPolicy) { - OAuth2AuthenticationPolicyConfiguration oauth2 = authPolicy.getOauth2(); - if (oauth2.getOAuth2ConnectAuthenticationProperties() != null) { - this.requestBuilder = - new OAuthRequestBuilder(application, oauth2.getOAuth2ConnectAuthenticationProperties()); - } else if (oauth2.getOAuth2AuthenticationPolicySecret() != null) { - throw new UnsupportedOperationException("Secrets are still not supported"); - } - } - - @Override - protected String authParameter(WorkflowContext workflow, TaskContext task, WorkflowModel model) { - return requestBuilder.build(workflow, task, model).validateAndGet().token(); - } - - @Override - protected String authScheme() { - return "Bearer"; + super( + accessToken( + workflow, + authPolicy.getOauth2().getOAuth2ConnectAuthenticationProperties(), + authPolicy.getOauth2().getOAuth2AuthenticationPolicySecret(), + new OAuthRequestBuilder(application))); } } diff --git a/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/OpenIdAuthProvider.java b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/OpenIdAuthProvider.java index deaa0e77..649708ad 100644 --- a/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/OpenIdAuthProvider.java +++ b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/OpenIdAuthProvider.java @@ -16,41 +16,21 @@ package io.serverlessworkflow.impl.executors.http; import io.serverlessworkflow.api.types.OpenIdConnectAuthenticationPolicy; -import io.serverlessworkflow.api.types.OpenIdConnectAuthenticationPolicyConfiguration; import io.serverlessworkflow.api.types.Workflow; -import io.serverlessworkflow.impl.TaskContext; import io.serverlessworkflow.impl.WorkflowApplication; -import io.serverlessworkflow.impl.WorkflowContext; -import io.serverlessworkflow.impl.WorkflowModel; -import io.serverlessworkflow.impl.executors.http.auth.requestbuilder.AuthRequestBuilder; import io.serverlessworkflow.impl.executors.http.auth.requestbuilder.OpenIdRequestBuilder; -public class OpenIdAuthProvider extends AbstractAuthProvider { - - private AuthRequestBuilder requestBuilder; +class OpenIdAuthProvider extends CommonOAuthProvider { public OpenIdAuthProvider( WorkflowApplication application, Workflow workflow, OpenIdConnectAuthenticationPolicy authPolicy) { - OpenIdConnectAuthenticationPolicyConfiguration configuration = authPolicy.getOidc(); - - if (configuration.getOpenIdConnectAuthenticationProperties() != null) { - this.requestBuilder = - new OpenIdRequestBuilder( - application, configuration.getOpenIdConnectAuthenticationProperties()); - } else if (configuration.getOpenIdConnectAuthenticationPolicySecret() != null) { - throw new UnsupportedOperationException("Secrets are still not supported"); - } - } - - @Override - protected String authParameter(WorkflowContext workflow, TaskContext task, WorkflowModel model) { - return requestBuilder.build(workflow, task, model).validateAndGet().token(); - } - - @Override - protected String authScheme() { - return "Bearer"; + super( + accessToken( + workflow, + authPolicy.getOidc().getOpenIdConnectAuthenticationProperties(), + authPolicy.getOidc().getOpenIdConnectAuthenticationPolicySecret(), + new OpenIdRequestBuilder(application))); } } diff --git a/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/SecretKeys.java b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/SecretKeys.java new file mode 100644 index 00000000..8f34268b --- /dev/null +++ b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/SecretKeys.java @@ -0,0 +1,37 @@ +/* + * 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.impl.executors.http; + +public class SecretKeys { + + private SecretKeys() {} + + public static final String GRANT = "grant"; + public static final String USER = "username"; + public static final String CLIENT = "client"; + public static final String PASSWORD = "password"; + public static final String ID = "id"; + public static final String SECRET = "secret"; + public static final String ISSUERS = "issuers"; + public static final String AUDIENCES = "audiences"; + public static final String ENDPOINTS = "endpoints"; + public static final String TOKEN = "token"; + public static final String AUTHORITY = "authority"; + public static final String SCOPES = "scopes"; + public static final String REQUEST = "request"; + public static final String ENCODING = "encoding"; + public static final String AUTHENTICATION = "authentication"; +} diff --git a/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/AbstractAuthRequestBuilder.java b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/AbstractAuthRequestBuilder.java index 1925982f..64031402 100644 --- a/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/AbstractAuthRequestBuilder.java +++ b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/AbstractAuthRequestBuilder.java @@ -16,89 +16,121 @@ package io.serverlessworkflow.impl.executors.http.auth.requestbuilder; import static io.serverlessworkflow.api.types.OAuth2AuthenticationDataClient.ClientAuthentication.CLIENT_SECRET_POST; +import static io.serverlessworkflow.impl.WorkflowUtils.isValid; +import static io.serverlessworkflow.impl.executors.http.SecretKeys.AUDIENCES; +import static io.serverlessworkflow.impl.executors.http.SecretKeys.AUTHENTICATION; +import static io.serverlessworkflow.impl.executors.http.SecretKeys.CLIENT; +import static io.serverlessworkflow.impl.executors.http.SecretKeys.ENCODING; +import static io.serverlessworkflow.impl.executors.http.SecretKeys.REQUEST; +import static io.serverlessworkflow.impl.executors.http.SecretKeys.SCOPES; import io.serverlessworkflow.api.types.OAuth2AuthenticationData; import io.serverlessworkflow.api.types.OAuth2AuthenticationDataClient; -import io.serverlessworkflow.impl.TaskContext; import io.serverlessworkflow.impl.WorkflowApplication; -import io.serverlessworkflow.impl.WorkflowContext; -import io.serverlessworkflow.impl.WorkflowModel; +import io.serverlessworkflow.impl.WorkflowUtils; import java.util.Arrays; import java.util.List; +import java.util.Map; import java.util.Objects; -import java.util.function.Consumer; import java.util.stream.Collectors; -abstract class AbstractAuthRequestBuilder implements AuthRequestBuilder { - - protected final OAuth2AuthenticationData authenticationData; +abstract class AbstractAuthRequestBuilder + implements AuthRequestBuilder { protected final WorkflowApplication application; + protected final HttpRequestInfoBuilder requestBuilder = new HttpRequestInfoBuilder(); - private final List> steps = - List.of( - this::requestEncoding, - this::authenticationURI, - this::audience, - this::scope, - this::authenticationMethod); - - protected AbstractAuthRequestBuilder( - OAuth2AuthenticationData authenticationData, WorkflowApplication application) { - this.authenticationData = authenticationData; + public AbstractAuthRequestBuilder(WorkflowApplication application) { this.application = application; } - protected void audience(HttpRequestBuilder requestBuilder) { + @Override + public HttpRequestInfo apply(T authenticationData) { + requestEncoding(authenticationData); + authenticationURI(authenticationData); + audience(authenticationData); + scope(authenticationData); + authenticationMethod(authenticationData); + return requestBuilder.build(); + } + + @Override + public HttpRequestInfo apply(Map secret) { + requestEncoding(secret); + authenticationURI(secret); + audience(secret); + scope(secret); + authenticationMethod(secret); + return requestBuilder.build(); + } + + protected void audience(T authenticationData) { if (authenticationData.getAudiences() != null && !authenticationData.getAudiences().isEmpty()) { String audiences = String.join(" ", authenticationData.getAudiences()); + requestBuilder.addQueryParam( + "audience", WorkflowUtils.buildStringFilter(application, audiences)); + } + } + + protected void audience(Map secret) { + String audiences = (String) secret.get(AUDIENCES); + if (isValid(audiences)) { requestBuilder.addQueryParam("audience", audiences); } } - protected void authenticationMethod(HttpRequestBuilder requestBuilder) { - switch (getClientAuthentication()) { + protected void authenticationMethod(T authenticationData) { + ClientSecretHandler secretHandler; + switch (getClientAuthentication(authenticationData)) { case CLIENT_SECRET_BASIC: - clientSecretBasic(requestBuilder); + secretHandler = new ClientSecretBasic(application, requestBuilder); case CLIENT_SECRET_JWT: throw new UnsupportedOperationException("Client Secret JWT is not supported yet"); case PRIVATE_KEY_JWT: throw new UnsupportedOperationException("Private Key JWT is not supported yet"); default: - clientSecretPost(requestBuilder); + secretHandler = new ClientSecretPost(application, requestBuilder); } + secretHandler.accept(authenticationData); } - private void clientSecretBasic(HttpRequestBuilder requestBuilder) { - new ClientSecretBasic(authenticationData).execute(requestBuilder); - } - - private void clientSecretPost(HttpRequestBuilder requestBuilder) { - new ClientSecretPost(authenticationData).execute(requestBuilder); - } - - private OAuth2AuthenticationDataClient.ClientAuthentication getClientAuthentication() { - if (authenticationData.getClient() == null - || authenticationData.getClient().getAuthentication() == null) { - return CLIENT_SECRET_POST; + protected void authenticationMethod(Map secret) { + Map client = (Map) secret.get(CLIENT); + ClientSecretHandler secretHandler; + String auth = (String) client.get(AUTHENTICATION); + if (auth == null) { + secretHandler = new ClientSecretPost(application, requestBuilder); + } else { + switch (auth) { + case "client_secret_basic": + secretHandler = new ClientSecretBasic(application, requestBuilder); + break; + default: + case "client_secret_post": + secretHandler = new ClientSecretPost(application, requestBuilder); + break; + case "private_key_jwt": + throw new UnsupportedOperationException("Private Key JWT is not supported yet"); + case "client_secret_jwt": + throw new UnsupportedOperationException("Client Secret JWT is not supported yet"); + } } - return authenticationData.getClient().getAuthentication(); + secretHandler.accept(secret); } - @Override - public AccessTokenProvider build( - WorkflowContext workflow, TaskContext task, WorkflowModel model) { - HttpRequestBuilder requestBuilder = new HttpRequestBuilder(application); - steps.forEach(step -> step.accept(requestBuilder)); - return new AccessTokenProvider( - requestBuilder.build(workflow, task, model), task, authenticationData.getIssuers()); + private OAuth2AuthenticationDataClient.ClientAuthentication getClientAuthentication( + OAuth2AuthenticationData authenticationData) { + return authenticationData.getClient() == null + || authenticationData.getClient().getAuthentication() == null + ? CLIENT_SECRET_POST + : authenticationData.getClient().getAuthentication(); } - protected void scope(HttpRequestBuilder requestBuilder) { - scope(requestBuilder, authenticationData.getScopes()); + protected void scope(T authenticationData) { + scope(authenticationData.getScopes()); } - protected void scope(HttpRequestBuilder requestBuilder, List scopesList) { + protected void scope(List scopesList) { if (scopesList == null || scopesList.isEmpty()) { return; } @@ -112,19 +144,30 @@ protected void scope(HttpRequestBuilder requestBuilder, List scopesList) .collect(Collectors.joining(" ")); if (!scope.isEmpty()) { - requestBuilder.addQueryParam("scope", scope); + requestBuilder.addQueryParam("scope", WorkflowUtils.buildStringFilter(application, scope)); } } - void requestEncoding(HttpRequestBuilder requestBuilder) { - if (authenticationData.getRequest() != null - && authenticationData.getRequest().getEncoding() != null) { - requestBuilder.addHeader( - "Content-Type", authenticationData.getRequest().getEncoding().value()); - } else { - requestBuilder.addHeader("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8"); + protected void scope(Map secret) { + String scopes = (String) secret.get(SCOPES); + if (isValid(scopes)) { + requestBuilder.addQueryParam("scope", scopes); } } - protected abstract void authenticationURI(HttpRequestBuilder requestBuilder); + void requestEncoding(T authenticationData) { + requestBuilder.withContentType(authenticationData.getRequest()); + } + + void requestEncoding(Map secret) { + Map request = (Map) secret.get(REQUEST); + String encoding = (String) request.get(ENCODING); + if (isValid(encoding)) { + requestBuilder.addHeader("Content-Type", encoding); + } + } + + protected abstract void authenticationURI(T authenticationData); + + protected abstract void authenticationURI(Map secret); } diff --git a/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/AccessTokenProvider.java b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/AccessTokenProvider.java index aea3dbd5..7989f3a6 100644 --- a/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/AccessTokenProvider.java +++ b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/AccessTokenProvider.java @@ -15,38 +15,46 @@ */ package io.serverlessworkflow.impl.executors.http.auth.requestbuilder; +import static io.serverlessworkflow.api.types.OAuth2TokenRequest.Oauth2TokenRequestEncoding.APPLICATION_X_WWW_FORM_URLENCODED; + import io.serverlessworkflow.impl.TaskContext; +import io.serverlessworkflow.impl.WorkflowContext; +import io.serverlessworkflow.impl.WorkflowError; +import io.serverlessworkflow.impl.WorkflowException; +import io.serverlessworkflow.impl.WorkflowModel; +import io.serverlessworkflow.impl.executors.http.HttpClientResolver; import io.serverlessworkflow.impl.executors.http.auth.jwt.JWT; import io.serverlessworkflow.impl.executors.http.auth.jwt.JWTConverter; +import jakarta.ws.rs.ProcessingException; +import jakarta.ws.rs.client.Client; +import jakarta.ws.rs.client.Entity; +import jakarta.ws.rs.client.Invocation; +import jakarta.ws.rs.client.ResponseProcessingException; +import jakarta.ws.rs.client.WebTarget; +import jakarta.ws.rs.core.Form; +import jakarta.ws.rs.core.GenericType; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.ServiceLoader; public class AccessTokenProvider { - private final TokenResponseHandler tokenResponseHandler = new TokenResponseHandler(); - - private final TaskContext context; private final List issuers; - private final InvocationHolder invocation; - + private final HttpRequestInfo requestInfo; private final JWTConverter jwtConverter; - AccessTokenProvider(InvocationHolder invocation, TaskContext context, List issuers) { - this.invocation = invocation; + AccessTokenProvider(HttpRequestInfo requestInfo, List issuers, JWTConverter converter) { + this.requestInfo = requestInfo; this.issuers = issuers; - this.context = context; - - this.jwtConverter = - ServiceLoader.load(JWTConverter.class) - .findFirst() - .orElseThrow(() -> new IllegalStateException("No JWTConverter implementation found")); + this.jwtConverter = converter; } - public JWT validateAndGet() { - Map token = tokenResponseHandler.apply(invocation, context); + public JWT validateAndGet(WorkflowContext workflow, TaskContext context, WorkflowModel model) { + Map token = invoke(workflow, context, model); JWT jwt = jwtConverter.fromToken((String) token.get("access_token")); - if (!(issuers == null || issuers.isEmpty())) { + if (issuers != null && !issuers.isEmpty()) { jwt.issuer() .ifPresent( issuer -> { @@ -57,4 +65,77 @@ public JWT validateAndGet() { } return jwt; } + + private Map invoke( + WorkflowContext workflowContext, TaskContext taskContext, WorkflowModel model) { + try { + Response response = executeRequest(workflowContext, taskContext, model); + + if (response.getStatus() < 200 || response.getStatus() >= 300) { + throw new WorkflowException( + WorkflowError.communication( + response.getStatus(), + taskContext, + "Failed to obtain token: HTTP " + + response.getStatus() + + " — " + + response.getEntity()) + .build()); + } + return response.readEntity(new GenericType<>() {}); + } catch (ResponseProcessingException e) { + throw new WorkflowException( + WorkflowError.communication( + e.getResponse().getStatus(), + taskContext, + "Failed to process response: " + e.getMessage()) + .build(), + e); + } catch (ProcessingException e) { + throw new WorkflowException( + WorkflowError.communication( + -1, taskContext, "Failed to connect or process request: " + e.getMessage()) + .build(), + e); + } + } + + private Response executeRequest(WorkflowContext workflow, TaskContext task, WorkflowModel model) { + + Client client = HttpClientResolver.client(workflow, task); + WebTarget target = client.target(requestInfo.uri().apply(workflow, task, model)); + + Invocation.Builder builder = target.request(MediaType.APPLICATION_JSON); + + builder.header("grant_type", requestInfo.grantType()); + builder.header("User-Agent", "OAuth2-Client-Credentials/1.0"); + builder.header("Accept", MediaType.APPLICATION_JSON); + builder.header("Cache-Control", "no-cache"); + + for (var entry : requestInfo.headers().entrySet()) { + String headerValue = entry.getValue().apply(workflow, task, model); + if (headerValue != null) { + builder.header(entry.getKey(), headerValue); + } + } + + Entity entity; + if (requestInfo.contentType().equals(APPLICATION_X_WWW_FORM_URLENCODED.value())) { + Form form = new Form(); + form.param("grant_type", requestInfo.grantType()); + requestInfo + .queryParams() + .forEach((key, value) -> form.param(key, value.apply(workflow, task, model))); + entity = Entity.entity(form, MediaType.APPLICATION_FORM_URLENCODED); + } else { + Map jsonData = new HashMap<>(); + jsonData.put("grant_type", requestInfo.grantType()); + requestInfo + .queryParams() + .forEach((key, value) -> jsonData.put(key, value.apply(workflow, task, model))); + entity = Entity.entity(jsonData, MediaType.APPLICATION_JSON); + } + + return builder.post(entity); + } } diff --git a/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/AccessTokenProviderFactory.java b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/AccessTokenProviderFactory.java new file mode 100644 index 00000000..73d1defe --- /dev/null +++ b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/AccessTokenProviderFactory.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.impl.executors.http.auth.requestbuilder; + +import static io.serverlessworkflow.impl.WorkflowUtils.secret; + +import io.serverlessworkflow.api.types.OAuth2AuthenticationData; +import io.serverlessworkflow.impl.WorkflowValueResolver; +import io.serverlessworkflow.impl.executors.http.auth.jwt.JWTConverter; +import java.util.Arrays; +import java.util.Map; +import java.util.ServiceLoader; + +public class AccessTokenProviderFactory { + + private AccessTokenProviderFactory() {} + + private static JWTConverter jwtConverter = + ServiceLoader.load(JWTConverter.class) + .findFirst() + .orElseThrow(() -> new IllegalStateException("No JWTConverter implementation found")); + + public static WorkflowValueResolver build( + OAuth2AuthenticationData authenticationData, AuthRequestBuilder authBuilder) { + AccessTokenProvider tokenProvider = + new AccessTokenProvider( + authBuilder.apply(authenticationData), authenticationData.getIssuers(), jwtConverter); + return (w, t, m) -> tokenProvider; + } + + public static WorkflowValueResolver build( + String secretName, AuthRequestBuilder authBuilder) { + return (w, t, m) -> { + Map secret = secret(w, secretName); + String issuers = (String) secret.get("issuers"); + return new AccessTokenProvider( + authBuilder.apply(secret), + issuers != null ? Arrays.asList(issuers.split(",")) : null, + jwtConverter); + }; + } +} diff --git a/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/AuthRequestBuilder.java b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/AuthRequestBuilder.java index 6fc808d3..833c5ef9 100644 --- a/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/AuthRequestBuilder.java +++ b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/AuthRequestBuilder.java @@ -15,11 +15,12 @@ */ package io.serverlessworkflow.impl.executors.http.auth.requestbuilder; -import io.serverlessworkflow.impl.TaskContext; -import io.serverlessworkflow.impl.WorkflowContext; -import io.serverlessworkflow.impl.WorkflowModel; +import io.serverlessworkflow.api.types.OAuth2AuthenticationData; +import java.util.Map; -public interface AuthRequestBuilder { +public interface AuthRequestBuilder { - public AccessTokenProvider build(WorkflowContext workflow, TaskContext task, WorkflowModel model); + HttpRequestInfo apply(T authenticationData); + + HttpRequestInfo apply(Map authenticationData); } diff --git a/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/ClientSecretBasic.java b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/ClientSecretBasic.java index 95b09abb..54227224 100644 --- a/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/ClientSecretBasic.java +++ b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/ClientSecretBasic.java @@ -15,72 +15,71 @@ */ package io.serverlessworkflow.impl.executors.http.auth.requestbuilder; -import static io.serverlessworkflow.api.types.OAuth2AuthenticationData.OAuth2AuthenticationDataGrant.CLIENT_CREDENTIALS; -import static io.serverlessworkflow.api.types.OAuth2AuthenticationData.OAuth2AuthenticationDataGrant.PASSWORD; +import static io.serverlessworkflow.impl.executors.http.SecretKeys.CLIENT; +import static io.serverlessworkflow.impl.executors.http.SecretKeys.GRANT; +import static io.serverlessworkflow.impl.executors.http.SecretKeys.ID; +import static io.serverlessworkflow.impl.executors.http.SecretKeys.PASSWORD; +import static io.serverlessworkflow.impl.executors.http.SecretKeys.SECRET; +import static io.serverlessworkflow.impl.executors.http.SecretKeys.USER; import io.serverlessworkflow.api.types.OAuth2AuthenticationData; +import io.serverlessworkflow.impl.WorkflowApplication; +import io.serverlessworkflow.impl.WorkflowUtils; import java.util.Base64; +import java.util.Map; -class ClientSecretBasic { +class ClientSecretBasic extends ClientSecretHandler { - private final OAuth2AuthenticationData authenticationData; - - ClientSecretBasic(OAuth2AuthenticationData authenticationData) { - this.authenticationData = authenticationData; + protected ClientSecretBasic( + WorkflowApplication application, HttpRequestInfoBuilder requestBuilder) { + super(application, requestBuilder); } - void execute(HttpRequestBuilder requestBuilder) { - if (authenticationData.getGrant().equals(PASSWORD)) { - password(requestBuilder, authenticationData); - } else if (authenticationData.getGrant().equals(CLIENT_CREDENTIALS)) { - clientCredentials(requestBuilder, authenticationData); - } else { - throw new UnsupportedOperationException( - "Unsupported grant type: " + authenticationData.getGrant()); - } + @Override + protected void clientCredentials(OAuth2AuthenticationData authenticationData) { + requestBuilder + .addHeader("Authorization", "Basic " + encodedAuth(authenticationData)) + .withGrantType(authenticationData.getGrant().value()); } - private void clientCredentials( - HttpRequestBuilder requestBuilder, OAuth2AuthenticationData authenticationData) { - if (authenticationData.getClient() == null - || authenticationData.getClient().getId() == null - || authenticationData.getClient().getSecret() == null) { - throw new IllegalArgumentException( - "Client ID and secret must be provided for client authentication"); - } + @Override + protected void password(OAuth2AuthenticationData authenticationData) { + clientCredentials(authenticationData); + requestBuilder + .addQueryParam( + "username", + WorkflowUtils.buildStringFilter(application, authenticationData.getUsername())) + .addQueryParam( + "password", + WorkflowUtils.buildStringFilter(application, authenticationData.getPassword())); + } - String idAndSecret = - authenticationData.getClient().getId() + ":" + authenticationData.getClient().getSecret(); - String encodedAuth = Base64.getEncoder().encodeToString(idAndSecret.getBytes()); + @Override + protected void clientCredentials(Map secret) { + requestBuilder + .withGrantType((String) secret.get(GRANT)) + .addHeader("Authorization", "Basic " + encodedAuth(secret)); + } + @Override + protected void password(Map secret) { + clientCredentials(secret); requestBuilder - .addHeader("Authorization", "Basic " + encodedAuth) - .withRequestContentType(authenticationData.getRequest()) - .withGrantType(authenticationData.getGrant()); + .addQueryParam("username", (String) secret.get(USER)) + .addQueryParam("password", (String) secret.get(PASSWORD)); } - private void password( - HttpRequestBuilder requestBuilder, OAuth2AuthenticationData authenticationData) { - if (authenticationData.getUsername() == null || authenticationData.getPassword() == null) { - throw new IllegalArgumentException( - "Username and password must be provided for password grant type"); - } - if (authenticationData.getClient() == null - || authenticationData.getClient().getId() == null - || authenticationData.getClient().getSecret() == null) { - throw new IllegalArgumentException( - "Client ID and secret must be provided for client authentication"); - } + private String encodedAuth(Map secret) { + Map client = (Map) secret.get(CLIENT); + return encodedAuth((String) client.get(ID), (String) client.get(SECRET)); + } - String idAndSecret = - authenticationData.getClient().getId() + ":" + authenticationData.getClient().getSecret(); - String encodedAuth = Base64.getEncoder().encodeToString(idAndSecret.getBytes()); + private String encodedAuth(OAuth2AuthenticationData authenticationData) { + return encodedAuth( + authenticationData.getClient().getId(), authenticationData.getClient().getSecret()); + } - requestBuilder - .withGrantType(authenticationData.getGrant()) - .withRequestContentType(authenticationData.getRequest()) - .addHeader("Authorization", "Basic " + encodedAuth) - .addQueryParam("username", authenticationData.getUsername()) - .addQueryParam("password", authenticationData.getPassword()); + private String encodedAuth(String id, String secret) { + return Base64.getEncoder().encodeToString((id + ":" + secret).getBytes()); } } diff --git a/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/ClientSecretHandler.java b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/ClientSecretHandler.java new file mode 100644 index 00000000..4f8ae73a --- /dev/null +++ b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/ClientSecretHandler.java @@ -0,0 +1,86 @@ +/* + * 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.impl.executors.http.auth.requestbuilder; + +import static io.serverlessworkflow.api.types.OAuth2AuthenticationData.OAuth2AuthenticationDataGrant.CLIENT_CREDENTIALS; +import static io.serverlessworkflow.api.types.OAuth2AuthenticationData.OAuth2AuthenticationDataGrant.PASSWORD; + +import io.serverlessworkflow.api.types.OAuth2AuthenticationData; +import io.serverlessworkflow.impl.WorkflowApplication; +import java.util.Map; +import java.util.Objects; + +abstract class ClientSecretHandler { + + protected final WorkflowApplication application; + protected final HttpRequestInfoBuilder requestBuilder; + + protected ClientSecretHandler( + WorkflowApplication application, HttpRequestInfoBuilder requestBuilder) { + this.application = application; + this.requestBuilder = requestBuilder; + } + + void accept(OAuth2AuthenticationData authenticationData) { + if (authenticationData.getGrant().equals(PASSWORD)) { + if (authenticationData.getUsername() == null || authenticationData.getPassword() == null) { + throw new IllegalArgumentException( + "Username and password must be provided for password grant type"); + } + if (authenticationData.getClient() == null + || authenticationData.getClient().getId() == null + || authenticationData.getClient().getSecret() == null) { + throw new IllegalArgumentException( + "Client ID and secret must be provided for client authentication"); + } + + password(authenticationData); + } else if (authenticationData.getGrant().equals(CLIENT_CREDENTIALS)) { + if (authenticationData.getClient() == null + || authenticationData.getClient().getId() == null + || authenticationData.getClient().getSecret() == null) { + throw new IllegalArgumentException( + "Client ID and secret must be provided for client authentication"); + } + clientCredentials(authenticationData); + } else { + throw new UnsupportedOperationException( + "Unsupported grant type: " + authenticationData.getGrant()); + } + } + + protected abstract void clientCredentials(OAuth2AuthenticationData authenticationData); + + protected abstract void password(OAuth2AuthenticationData authenticationData); + + protected abstract void clientCredentials(Map secret); + + protected abstract void password(Map secret); + + void accept(Map secret) { + String grant = Objects.requireNonNull((String) secret.get("grant"), "Grant is mandatory field"); + switch (grant) { + case "client_credentials": + clientCredentials(secret); + break; + case "password": + password(secret); + break; + default: + throw new UnsupportedOperationException("Unsupported grant type: " + grant); + } + } +} diff --git a/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/ClientSecretPost.java b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/ClientSecretPost.java index 21302e30..f0d3cbff 100644 --- a/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/ClientSecretPost.java +++ b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/ClientSecretPost.java @@ -15,64 +15,63 @@ */ package io.serverlessworkflow.impl.executors.http.auth.requestbuilder; -import static io.serverlessworkflow.api.types.OAuth2AuthenticationData.OAuth2AuthenticationDataGrant.CLIENT_CREDENTIALS; -import static io.serverlessworkflow.api.types.OAuth2AuthenticationData.OAuth2AuthenticationDataGrant.PASSWORD; +import static io.serverlessworkflow.impl.executors.http.SecretKeys.CLIENT; +import static io.serverlessworkflow.impl.executors.http.SecretKeys.GRANT; +import static io.serverlessworkflow.impl.executors.http.SecretKeys.ID; +import static io.serverlessworkflow.impl.executors.http.SecretKeys.PASSWORD; +import static io.serverlessworkflow.impl.executors.http.SecretKeys.SECRET; +import static io.serverlessworkflow.impl.executors.http.SecretKeys.USER; import io.serverlessworkflow.api.types.OAuth2AuthenticationData; +import io.serverlessworkflow.impl.WorkflowApplication; +import io.serverlessworkflow.impl.WorkflowUtils; +import java.util.Map; -class ClientSecretPost { - private final OAuth2AuthenticationData authenticationData; +class ClientSecretPost extends ClientSecretHandler { - ClientSecretPost(OAuth2AuthenticationData authenticationData) { - this.authenticationData = authenticationData; + protected ClientSecretPost( + WorkflowApplication application, HttpRequestInfoBuilder requestBuilder) { + super(application, requestBuilder); } - void execute(HttpRequestBuilder requestBuilder) { - if (authenticationData.getGrant().equals(PASSWORD)) { - password(requestBuilder, authenticationData); - } else if (authenticationData.getGrant().equals(CLIENT_CREDENTIALS)) { - clientCredentials(requestBuilder, authenticationData); - } else { - throw new UnsupportedOperationException( - "Unsupported grant type: " + authenticationData.getGrant()); - } + @Override + protected void clientCredentials(OAuth2AuthenticationData authenticationData) { + requestBuilder + .withGrantType(authenticationData.getGrant().value()) + .addQueryParam( + "client_id", + WorkflowUtils.buildStringFilter(application, authenticationData.getClient().getId())) + .addQueryParam( + "client_secret", + WorkflowUtils.buildStringFilter( + application, authenticationData.getClient().getSecret())); } - private void clientCredentials( - HttpRequestBuilder requestBuilder, OAuth2AuthenticationData authenticationData) { - if (authenticationData.getClient() == null - || authenticationData.getClient().getId() == null - || authenticationData.getClient().getSecret() == null) { - throw new IllegalArgumentException( - "Client ID and secret must be provided for client authentication"); - } - + @Override + protected void password(OAuth2AuthenticationData authenticationData) { + clientCredentials(authenticationData); requestBuilder - .withGrantType(authenticationData.getGrant()) - .withRequestContentType(authenticationData.getRequest()) - .addQueryParam("client_id", authenticationData.getClient().getId()) - .addQueryParam("client_secret", authenticationData.getClient().getSecret()); + .addQueryParam( + "username", + WorkflowUtils.buildStringFilter(application, authenticationData.getUsername())) + .addQueryParam( + "password", + WorkflowUtils.buildStringFilter(application, authenticationData.getPassword())); } - private void password( - HttpRequestBuilder requestBuilder, OAuth2AuthenticationData authenticationData) { - if (authenticationData.getUsername() == null || authenticationData.getPassword() == null) { - throw new IllegalArgumentException( - "Username and password must be provided for password grant type"); - } - if (authenticationData.getClient() == null - || authenticationData.getClient().getId() == null - || authenticationData.getClient().getSecret() == null) { - throw new IllegalArgumentException( - "Client ID and secret must be provided for client authentication"); - } - + @Override + protected void clientCredentials(Map secret) { + Map client = (Map) secret.get(CLIENT); requestBuilder - .withGrantType(authenticationData.getGrant()) - .withRequestContentType(authenticationData.getRequest()) - .addQueryParam("client_id", authenticationData.getClient().getId()) - .addQueryParam("client_secret", authenticationData.getClient().getSecret()) - .addQueryParam("username", authenticationData.getUsername()) - .addQueryParam("password", authenticationData.getPassword()); + .withGrantType((String) secret.get(GRANT)) + .addQueryParam("client_id", (String) client.get(ID)) + .addQueryParam("client_secret", (String) client.get(SECRET)); + } + + @Override + protected void password(Map secret) { + clientCredentials(secret); + requestBuilder.addQueryParam("username", (String) secret.get(USER)); + requestBuilder.addQueryParam("password", (String) secret.get(PASSWORD)); } } diff --git a/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/HttpRequestBuilder.java b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/HttpRequestBuilder.java deleted file mode 100644 index 14f90421..00000000 --- a/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/HttpRequestBuilder.java +++ /dev/null @@ -1,137 +0,0 @@ -/* - * Copyright 2020-Present The Serverless Workflow Specification Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package io.serverlessworkflow.impl.executors.http.auth.requestbuilder; - -import static io.serverlessworkflow.api.types.OAuth2TokenRequest.Oauth2TokenRequestEncoding.APPLICATION_X_WWW_FORM_URLENCODED; - -import io.serverlessworkflow.api.types.OAuth2AuthenticationData; -import io.serverlessworkflow.api.types.OAuth2TokenRequest; -import io.serverlessworkflow.api.types.OAuth2TokenRequest.Oauth2TokenRequestEncoding; -import io.serverlessworkflow.impl.TaskContext; -import io.serverlessworkflow.impl.WorkflowApplication; -import io.serverlessworkflow.impl.WorkflowContext; -import io.serverlessworkflow.impl.WorkflowModel; -import io.serverlessworkflow.impl.WorkflowUtils; -import io.serverlessworkflow.impl.WorkflowValueResolver; -import jakarta.ws.rs.client.Client; -import jakarta.ws.rs.client.ClientBuilder; -import jakarta.ws.rs.client.Entity; -import jakarta.ws.rs.client.Invocation; -import jakarta.ws.rs.client.WebTarget; -import jakarta.ws.rs.core.Form; -import jakarta.ws.rs.core.MediaType; -import java.net.URI; -import java.util.HashMap; -import java.util.Map; -import java.util.Objects; - -class HttpRequestBuilder { - - private final Map> headers; - - private final Map> queryParams; - - private final WorkflowApplication app; - - private URI uri; - - private OAuth2AuthenticationData.OAuth2AuthenticationDataGrant grantType; - - private Oauth2TokenRequestEncoding requestContentType = APPLICATION_X_WWW_FORM_URLENCODED; - - HttpRequestBuilder(WorkflowApplication app) { - this.app = app; - headers = new HashMap<>(); - queryParams = new HashMap<>(); - } - - HttpRequestBuilder addHeader(String key, String token) { - headers.put(key, WorkflowUtils.buildStringFilter(app, token)); - return this; - } - - HttpRequestBuilder addQueryParam(String key, String token) { - queryParams.put(key, WorkflowUtils.buildStringFilter(app, token)); - return this; - } - - HttpRequestBuilder withUri(URI uri) { - this.uri = uri; - return this; - } - - HttpRequestBuilder withRequestContentType(OAuth2TokenRequest oAuth2TokenRequest) { - if (oAuth2TokenRequest != null) { - this.requestContentType = oAuth2TokenRequest.getEncoding(); - } - return this; - } - - HttpRequestBuilder withGrantType( - OAuth2AuthenticationData.OAuth2AuthenticationDataGrant grantType) { - this.grantType = grantType; - return this; - } - - InvocationHolder build(WorkflowContext workflow, TaskContext task, WorkflowModel model) { - validate(); - - Client client = ClientBuilder.newClient(); - WebTarget target = client.target(uri); - - Invocation.Builder builder = target.request(MediaType.APPLICATION_JSON); - - builder.header("grant_type", grantType.name().toLowerCase()); - builder.header("User-Agent", "OAuth2-Client-Credentials/1.0"); - builder.header("Accept", MediaType.APPLICATION_JSON); - builder.header("Cache-Control", "no-cache"); - - for (var entry : headers.entrySet()) { - String headerValue = entry.getValue().apply(workflow, task, model); - if (headerValue != null) { - builder.header(entry.getKey(), headerValue); - } - } - - Entity entity; - if (requestContentType.equals(APPLICATION_X_WWW_FORM_URLENCODED)) { - Form form = new Form(); - form.param("grant_type", grantType.value()); - queryParams.forEach( - (key, value) -> { - String resolved = value.apply(workflow, task, model); - form.param(key, resolved); - }); - entity = Entity.entity(form, MediaType.APPLICATION_FORM_URLENCODED); - } else { - Map jsonData = new HashMap<>(); - jsonData.put("grant_type", grantType.value()); - queryParams.forEach( - (key, value) -> { - String resolved = value.apply(workflow, task, model); - jsonData.put(key, resolved); - }); - entity = Entity.entity(jsonData, MediaType.APPLICATION_JSON); - } - - return new InvocationHolder(client, () -> builder.post(entity)); - } - - private void validate() { - Objects.requireNonNull(uri, "URI must be set before building the request"); - Objects.requireNonNull(grantType, "Grant type must be set before building the request"); - } -} diff --git a/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/InvocationHolder.java b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/HttpRequestInfo.java similarity index 55% rename from impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/InvocationHolder.java rename to impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/HttpRequestInfo.java index e1cca56e..b2ef5c3f 100644 --- a/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/InvocationHolder.java +++ b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/HttpRequestInfo.java @@ -15,29 +15,13 @@ */ package io.serverlessworkflow.impl.executors.http.auth.requestbuilder; -import jakarta.ws.rs.client.Client; -import jakarta.ws.rs.core.Response; -import java.io.Closeable; -import java.util.concurrent.Callable; -import java.util.function.Supplier; +import io.serverlessworkflow.impl.WorkflowValueResolver; +import java.net.URI; +import java.util.Map; -class InvocationHolder implements Callable, Closeable { - - private final Client client; - private final Supplier call; - - InvocationHolder(Client client, Supplier call) { - this.client = client; - this.call = call; - } - - public Response call() { - return call.get(); - } - - public void close() { - if (client != null) { - client.close(); - } - } -} +record HttpRequestInfo( + Map> headers, + Map> queryParams, + WorkflowValueResolver uri, + String grantType, + String contentType) {} diff --git a/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/HttpRequestInfoBuilder.java b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/HttpRequestInfoBuilder.java new file mode 100644 index 00000000..a9d9f0bd --- /dev/null +++ b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/HttpRequestInfoBuilder.java @@ -0,0 +1,96 @@ +/* + * 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.impl.executors.http.auth.requestbuilder; + +import static io.serverlessworkflow.api.types.OAuth2TokenRequest.Oauth2TokenRequestEncoding.APPLICATION_X_WWW_FORM_URLENCODED; + +import io.serverlessworkflow.api.types.OAuth2TokenRequest; +import io.serverlessworkflow.impl.WorkflowValueResolver; +import java.net.URI; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +class HttpRequestInfoBuilder { + + private Map> headers; + + private Map> queryParams; + + private WorkflowValueResolver uri; + + private String grantType; + + private String contentType; + + HttpRequestInfoBuilder() { + headers = new HashMap<>(); + queryParams = new HashMap<>(); + } + + HttpRequestInfoBuilder addHeader(String key, String token) { + headers.put(key, (w, t, m) -> token); + return this; + } + + HttpRequestInfoBuilder addHeader(String key, WorkflowValueResolver token) { + headers.put(key, token); + return this; + } + + HttpRequestInfoBuilder addQueryParam(String key, String token) { + queryParams.put(key, (w, t, m) -> token); + return this; + } + + HttpRequestInfoBuilder addQueryParam(String key, WorkflowValueResolver token) { + queryParams.put(key, token); + return this; + } + + HttpRequestInfoBuilder withUri(WorkflowValueResolver uri) { + this.uri = uri; + return this; + } + + HttpRequestInfoBuilder withContentType(OAuth2TokenRequest oAuth2TokenRequest) { + if (oAuth2TokenRequest != null) { + this.contentType = oAuth2TokenRequest.getEncoding().value(); + } + return this; + } + + HttpRequestInfoBuilder withContentType(String contentType) { + if (contentType != null) { + this.contentType = contentType; + } + return this; + } + + HttpRequestInfoBuilder withGrantType(String grantType) { + this.grantType = grantType; + return this; + } + + HttpRequestInfo build() { + Objects.requireNonNull(uri, "URI must be set before building the request"); + Objects.requireNonNull(grantType, "Grant type must be set before building the request"); + if (contentType == null) { + contentType = APPLICATION_X_WWW_FORM_URLENCODED.value(); + } + return new HttpRequestInfo(headers, queryParams, uri, grantType, contentType); + } +} diff --git a/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/OAuthRequestBuilder.java b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/OAuthRequestBuilder.java index 6ef16fcd..df93b95d 100644 --- a/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/OAuthRequestBuilder.java +++ b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/OAuthRequestBuilder.java @@ -15,37 +15,50 @@ */ package io.serverlessworkflow.impl.executors.http.auth.requestbuilder; +import static io.serverlessworkflow.impl.WorkflowUtils.concatURI; +import static io.serverlessworkflow.impl.executors.http.SecretKeys.AUTHORITY; + import io.serverlessworkflow.api.types.OAuth2AuthenticationPropertiesEndpoints; import io.serverlessworkflow.api.types.OAuth2ConnectAuthenticationProperties; import io.serverlessworkflow.impl.WorkflowApplication; +import io.serverlessworkflow.impl.WorkflowUtils; +import io.serverlessworkflow.impl.WorkflowValueResolver; import java.net.URI; import java.util.Map; -public class OAuthRequestBuilder extends AbstractAuthRequestBuilder { +public class OAuthRequestBuilder + extends AbstractAuthRequestBuilder { + + private static String DEFAULT_TOKEN_PATH = "oauth2/token"; - private final Map defaults = - Map.of( - "endpoints.token", "oauth2/token", - "endpoints.revocation", "oauth2/revoke", - "endpoints.introspection", "oauth2/introspect"); + public OAuthRequestBuilder(WorkflowApplication application) { + super(application); + } - public OAuthRequestBuilder( - WorkflowApplication application, - OAuth2ConnectAuthenticationProperties oAuth2ConnectAuthenticationProperties) { - super(oAuth2ConnectAuthenticationProperties, application); + // TODO handle revocation and introspection path + // private static String DEFAULT_REVOCATION_PATH = "oauth2/revoke"; + // private static String DEFAULT_INTROSPECTION_PATH = "oauth2/introspect"; + + @Override + protected void authenticationURI(OAuth2ConnectAuthenticationProperties authenticationData) { + OAuth2AuthenticationPropertiesEndpoints endpoints = authenticationData.getEndpoints(); + WorkflowValueResolver uri = + WorkflowUtils.getURISupplier(application, authenticationData.getAuthority()); + String tokenPath = + endpoints != null && endpoints.getToken() != null + ? endpoints.getToken().replaceAll("^/", "") + : DEFAULT_TOKEN_PATH; + requestBuilder.withUri((w, t, m) -> concatURI(uri.apply(w, t, m), tokenPath)); } @Override - protected void authenticationURI(HttpRequestBuilder requestBuilder) { - OAuth2AuthenticationPropertiesEndpoints endpoints = - ((OAuth2ConnectAuthenticationProperties) authenticationData).getEndpoints(); - - String baseUri = - authenticationData.getAuthority().getLiteralUri().toString().replaceAll("/$", ""); - String tokenPath = defaults.get("endpoints.token"); - if (endpoints != null && endpoints.getToken() != null) { - tokenPath = endpoints.getToken().replaceAll("^/", ""); - } - requestBuilder.withUri(URI.create(baseUri + "/" + tokenPath)); + protected void authenticationURI(Map secret) { + String tokenPath = + secret.get("endpoints") instanceof Map endpoints ? (String) endpoints.get("token") : null; + URI uri = + concatURI( + URI.create((String) secret.get(AUTHORITY)), + tokenPath == null ? DEFAULT_TOKEN_PATH : tokenPath); + requestBuilder.withUri((w, t, m) -> uri); } } diff --git a/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/OpenIdRequestBuilder.java b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/OpenIdRequestBuilder.java index eb082f59..6059228a 100644 --- a/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/OpenIdRequestBuilder.java +++ b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/OpenIdRequestBuilder.java @@ -15,29 +15,38 @@ */ package io.serverlessworkflow.impl.executors.http.auth.requestbuilder; +import static io.serverlessworkflow.impl.executors.http.SecretKeys.AUTHORITY; + import io.serverlessworkflow.api.types.OAuth2AuthenticationData; import io.serverlessworkflow.impl.WorkflowApplication; +import io.serverlessworkflow.impl.WorkflowUtils; import java.net.URI; import java.util.ArrayList; import java.util.List; +import java.util.Map; -public class OpenIdRequestBuilder extends AbstractAuthRequestBuilder { +public class OpenIdRequestBuilder extends AbstractAuthRequestBuilder { - public OpenIdRequestBuilder( - WorkflowApplication application, OAuth2AuthenticationData autenthicationData) { - super(autenthicationData, application); + public OpenIdRequestBuilder(WorkflowApplication application) { + super(application); } @Override - protected void authenticationURI(HttpRequestBuilder requestBuilder) { - String url = authenticationData.getAuthority().getLiteralUri().toString().replaceAll("/$", ""); - requestBuilder.withUri(URI.create(url)); + protected void authenticationURI(OAuth2AuthenticationData authenticationData) { + requestBuilder.withUri( + WorkflowUtils.getURISupplier(application, authenticationData.getAuthority())); } @Override - protected void scope(HttpRequestBuilder requestBuilder) { + protected void scope(OAuth2AuthenticationData authenticationData) { List scopesList = new ArrayList<>(authenticationData.getScopes()); scopesList.add("openid"); - scope(requestBuilder, scopesList); + scope(scopesList); + } + + @Override + protected void authenticationURI(Map secret) { + URI uri = URI.create((String) secret.get(AUTHORITY)); + requestBuilder.withUri((w, t, m) -> uri); } } diff --git a/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/TokenResponseHandler.java b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/TokenResponseHandler.java deleted file mode 100644 index 2d64b7ec..00000000 --- a/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/auth/requestbuilder/TokenResponseHandler.java +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright 2020-Present The Serverless Workflow Specification Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package io.serverlessworkflow.impl.executors.http.auth.requestbuilder; - -import io.serverlessworkflow.impl.TaskContext; -import io.serverlessworkflow.impl.WorkflowError; -import io.serverlessworkflow.impl.WorkflowException; -import jakarta.ws.rs.ProcessingException; -import jakarta.ws.rs.client.ResponseProcessingException; -import jakarta.ws.rs.core.GenericType; -import jakarta.ws.rs.core.Response; -import java.util.Map; -import java.util.function.BiFunction; - -class TokenResponseHandler - implements BiFunction> { - - @Override - public Map apply(InvocationHolder invocation, TaskContext context) { - try (Response response = invocation.call()) { - if (response.getStatus() < 200 || response.getStatus() >= 300) { - throw new WorkflowException( - WorkflowError.communication( - response.getStatus(), - context, - "Failed to obtain token: HTTP " - + response.getStatus() - + " — " - + response.getEntity()) - .build()); - } - return response.readEntity(new GenericType<>() {}); - } catch (ResponseProcessingException e) { - throw new WorkflowException( - WorkflowError.communication( - e.getResponse().getStatus(), - context, - "Failed to process response: " + e.getMessage()) - .build(), - e); - } catch (ProcessingException e) { - throw new WorkflowException( - WorkflowError.communication( - -1, context, "Failed to connect or process request: " + e.getMessage()) - .build(), - e); - } finally { - invocation.close(); - } - } -} diff --git a/impl/jq/src/main/java/io/serverlessworkflow/impl/expressions/jq/JQExpression.java b/impl/jq/src/main/java/io/serverlessworkflow/impl/expressions/jq/JQExpression.java index 8f95ed06..21edca3a 100644 --- a/impl/jq/src/main/java/io/serverlessworkflow/impl/expressions/jq/JQExpression.java +++ b/impl/jq/src/main/java/io/serverlessworkflow/impl/expressions/jq/JQExpression.java @@ -108,7 +108,7 @@ private Scope createScope(WorkflowContext workflow, TaskContext task) { "secret", new FunctionJsonNode( k -> { - Map secret = + Map secret = workflow.definition().application().secretManager().secret(k); if (secret.isEmpty()) { throw new WorkflowException(WorkflowError.authorization().build()); diff --git a/impl/test/src/test/resources/workflows-samples/oAuthClientSecretPostPasswordAllGrantsHttpCall.yaml b/impl/test/src/test/resources/workflows-samples/oAuthClientSecretPostPasswordAllGrantsHttpCall.yaml index 804d8932..78ee8fef 100644 --- a/impl/test/src/test/resources/workflows-samples/oAuthClientSecretPostPasswordAllGrantsHttpCall.yaml +++ b/impl/test/src/test/resources/workflows-samples/oAuthClientSecretPostPasswordAllGrantsHttpCall.yaml @@ -12,7 +12,7 @@ do: uri: http://localhost:8081/hello authentication: oauth2: - authority: http://localhost:8888/realms/test-realm + authority: http://localhost:8888/realms/test-realm/ grant: password request: encoding: application/x-www-form-urlencoded