diff --git a/impl/openapi/pom.xml b/impl/openapi/pom.xml
new file mode 100644
index 00000000..dcda06e4
--- /dev/null
+++ b/impl/openapi/pom.xml
@@ -0,0 +1,53 @@
+
+ 4.0.0
+
+ io.serverlessworkflow
+ serverlessworkflow-impl
+ 8.0.0-SNAPSHOT
+
+ serverlessworkflow-impl-openapi
+ Serverless Workflow :: Impl :: OpenAPI
+
+
+ org.glassfish.jersey.core
+ jersey-client
+
+
+ org.glassfish.jersey.media
+ jersey-media-json-jackson
+
+
+ io.serverlessworkflow
+ serverlessworkflow-impl-core
+
+
+ io.swagger.parser.v3
+ swagger-parser
+
+
+ jakarta.ws.rs
+ jakarta.ws.rs-api
+
+
+ org.junit.jupiter
+ junit-jupiter-api
+ test
+
+
+ org.junit.jupiter
+ junit-jupiter-engine
+ test
+
+
+ org.junit.jupiter
+ junit-jupiter-params
+ test
+
+
+ org.assertj
+ assertj-core
+ test
+
+
+
\ No newline at end of file
diff --git a/impl/openapi/src/main/java/io/serverlessworkflow/impl/executors/OpenAPIExecutor.java b/impl/openapi/src/main/java/io/serverlessworkflow/impl/executors/OpenAPIExecutor.java
new file mode 100644
index 00000000..ee27be90
--- /dev/null
+++ b/impl/openapi/src/main/java/io/serverlessworkflow/impl/executors/OpenAPIExecutor.java
@@ -0,0 +1,198 @@
+/*
+ * 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.CallOpenAPI;
+import io.serverlessworkflow.api.types.OpenAPIArguments;
+import io.serverlessworkflow.api.types.TaskBase;
+import io.serverlessworkflow.api.types.UriTemplate;
+import io.serverlessworkflow.api.types.WithOpenAPIParameters;
+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.WorkflowError;
+import io.serverlessworkflow.impl.WorkflowException;
+import io.serverlessworkflow.impl.WorkflowModel;
+import io.serverlessworkflow.impl.resources.ResourceLoader;
+import io.swagger.v3.oas.models.OpenAPI;
+import io.swagger.v3.parser.OpenAPIV3Parser;
+import jakarta.ws.rs.WebApplicationException;
+import jakarta.ws.rs.client.Client;
+import jakarta.ws.rs.client.ClientBuilder;
+import jakarta.ws.rs.client.Invocation;
+import jakarta.ws.rs.client.WebTarget;
+import jakarta.ws.rs.core.MultivaluedMap;
+import jakarta.ws.rs.core.Response;
+import java.net.URI;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+
+public class OpenAPIExecutor implements CallableTask {
+
+ private static final Client client = ClientBuilder.newClient();
+ private WebTargetSupplier webTargetSupplier;
+ private RequestSupplier requestSupplier;
+ private OpenAPIModelConverter converter = new OpenAPIModelConverter() {};
+
+ @FunctionalInterface
+ private interface WebTargetSupplier {
+ WebTarget apply();
+ }
+
+ @FunctionalInterface
+ private interface RequestSupplier {
+ WorkflowModel apply(
+ Invocation.Builder request, WorkflowContext workflow, TaskContext task, WorkflowModel node);
+ }
+
+ @Override
+ public void init(
+ CallOpenAPI task, Workflow workflow, WorkflowApplication application, ResourceLoader loader) {
+ OpenAPIArguments args = task.getWith();
+
+ URI uri = getOpenAPIDocumentURI(args.getDocument().getEndpoint().getUriTemplate());
+
+ OpenAPIV3Parser apiv3Parser = new OpenAPIV3Parser();
+
+ OpenAPI openAPI = apiv3Parser.read(uri.toString());
+
+ OpenAPIOperationContext ctx = generateContext(openAPI, args, uri);
+
+ WithOpenAPIParameters withParams =
+ Optional.ofNullable(args.getParameters()).orElse(new WithOpenAPIParameters());
+
+ this.webTargetSupplier = getTargetSupplier(openAPI, ctx, withParams);
+
+ this.requestSupplier =
+ (request, w, taskContext, node) -> {
+ try {
+ Response response = request.method(ctx.httpMethodName(), Response.class);
+
+ if (!args.isRedirect() && !is2xx(response)) {
+ throw new WorkflowException(
+ WorkflowError.communication(
+ response.getStatus(),
+ taskContext,
+ "Received a non-2xx nor 3xx response but redirects are enabled")
+ .build());
+ }
+
+ if (args.isRedirect() && isNot2xxNor3xx(response)) {
+ throw new WorkflowException(
+ WorkflowError.communication(
+ response.getStatus(),
+ taskContext,
+ "Received a non-2xx nor 3xx response but redirects are enabled")
+ .build());
+ }
+
+ return converter.toModel(
+ application.modelFactory(), node, response.readEntity(node.objectClass()));
+ } catch (WebApplicationException exception) {
+ throw new WorkflowException(
+ WorkflowError.communication(
+ exception.getResponse().getStatus(), taskContext, exception)
+ .build());
+ }
+ };
+ }
+
+ private static WebTargetSupplier getTargetSupplier(
+ OpenAPI openAPI, OpenAPIOperationContext ctx, WithOpenAPIParameters withParams) {
+ return () -> {
+ WebTarget webTarget =
+ client
+ .target(openAPI.getServers().get(0).getUrl())
+ .path(ctx.buildPath(withParams.getAdditionalProperties()));
+
+ MultivaluedMap queryParams =
+ ctx.buildQueryParams(withParams.getAdditionalProperties());
+
+ for (Map.Entry> queryParam : queryParams.entrySet()) {
+ for (Object value : queryParam.getValue()) {
+ webTarget = webTarget.queryParam(queryParam.getKey(), value);
+ }
+ }
+
+ return webTarget;
+ };
+ }
+
+ private static boolean is2xx(Response response) {
+ return response.getStatusInfo().getFamily().equals(Response.Status.Family.SUCCESSFUL);
+ }
+
+ private static boolean isNot2xxNor3xx(Response response) {
+ return !(response.getStatusInfo().getFamily().equals(Response.Status.Family.SUCCESSFUL)
+ || response.getStatusInfo().getFamily().equals(Response.Status.Family.REDIRECTION));
+ }
+
+ private static OpenAPIOperationContext generateContext(
+ OpenAPI openAPI, OpenAPIArguments args, URI uri) {
+ return openAPI.getPaths().entrySet().stream()
+ .flatMap(
+ pathEntry ->
+ pathEntry.getValue().readOperationsMap().entrySet().stream()
+ .map(
+ operationEntry ->
+ new OpenAPIOperationContext(
+ operationEntry.getValue().getOperationId(),
+ pathEntry.getKey(),
+ operationEntry.getKey(),
+ operationEntry.getValue())))
+ .filter(c -> c.operationId().equals(args.getOperationId()))
+ .findFirst()
+ .orElseThrow(
+ () ->
+ new IllegalArgumentException(
+ "Operation with id "
+ + args.getOperationId()
+ + " not found in OpenAPI document "
+ + uri));
+ }
+
+ @Override
+ public CompletableFuture apply(
+ WorkflowContext workflowContext, TaskContext taskContext, WorkflowModel input) {
+
+ return CompletableFuture.supplyAsync(
+ () -> {
+ WebTarget target = webTargetSupplier.apply();
+ Invocation.Builder request = target.request();
+ return requestSupplier.apply(request, workflowContext, taskContext, input);
+ },
+ workflowContext.definition().application().executorService());
+ }
+
+ @Override
+ public boolean accept(Class extends TaskBase> clazz) {
+ return clazz.equals(CallOpenAPI.class);
+ }
+
+ private static URI getOpenAPIDocumentURI(UriTemplate template) {
+ if (template.getLiteralUri() != null) {
+ return template.getLiteralUri();
+ } else if (template.getLiteralUriTemplate() != null) {
+ // https://github.com/serverlessworkflow/specification/blob/main/dsl-reference.md#uri-template
+ throw new UnsupportedOperationException(
+ "URI templates with parameters are not supported yet");
+ }
+ throw new IllegalArgumentException("Invalid UriTemplate definition " + template);
+ }
+}
diff --git a/impl/openapi/src/main/java/io/serverlessworkflow/impl/executors/OpenAPIModelConverter.java b/impl/openapi/src/main/java/io/serverlessworkflow/impl/executors/OpenAPIModelConverter.java
new file mode 100644
index 00000000..c12fc811
--- /dev/null
+++ b/impl/openapi/src/main/java/io/serverlessworkflow/impl/executors/OpenAPIModelConverter.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.executors;
+
+import io.serverlessworkflow.impl.WorkflowModel;
+import io.serverlessworkflow.impl.WorkflowModelFactory;
+import jakarta.ws.rs.client.Entity;
+import java.util.Map;
+
+public interface OpenAPIModelConverter {
+
+ default WorkflowModel toModel(WorkflowModelFactory factory, WorkflowModel model, Object entity) {
+ return factory.fromAny(model, entity);
+ }
+
+ default Entity toEntity(Map model) {
+ return Entity.json(model);
+ }
+}
diff --git a/impl/openapi/src/main/java/io/serverlessworkflow/impl/executors/OpenAPIOperationContext.java b/impl/openapi/src/main/java/io/serverlessworkflow/impl/executors/OpenAPIOperationContext.java
new file mode 100644
index 00000000..eb89e326
--- /dev/null
+++ b/impl/openapi/src/main/java/io/serverlessworkflow/impl/executors/OpenAPIOperationContext.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.impl.executors;
+
+import io.swagger.v3.oas.models.Operation;
+import io.swagger.v3.oas.models.PathItem;
+import io.swagger.v3.oas.models.parameters.Parameter;
+import jakarta.ws.rs.core.MultivaluedHashMap;
+import jakarta.ws.rs.core.MultivaluedMap;
+import java.util.Map;
+import java.util.Objects;
+
+public record OpenAPIOperationContext(
+ String operationId, String path, PathItem.HttpMethod httpMethod, Operation operation) {
+
+ public String httpMethodName() {
+ return httpMethod.name();
+ }
+
+ public String buildPath(Map replacements) {
+ String finalPath = path;
+ if (Objects.isNull(operation.getParameters())) {
+ return "";
+ }
+ for (Parameter parameter : operation.getParameters()) {
+ if ("path".equals(parameter.getIn())) {
+ String name = parameter.getName();
+ Object value = replacements.get(name);
+ if (value != null) {
+ finalPath = path.replaceAll("\\{\\s*" + name + "\\s*}", value.toString());
+ }
+ }
+ }
+ return finalPath;
+ }
+
+ public MultivaluedMap buildQueryParams(Map replacements) {
+ if (Objects.isNull(operation.getParameters())) {
+ return new MultivaluedHashMap<>();
+ }
+ MultivaluedMap queryParams = new MultivaluedHashMap<>();
+ for (Parameter parameter : operation.getParameters()) {
+ if ("query".equals(parameter.getIn())) {
+ String name = parameter.getName();
+ Object value = replacements.get(name);
+ if (value != null) {
+ queryParams.add(name, value.toString());
+ }
+ }
+ }
+ return queryParams;
+ }
+}
diff --git a/impl/openapi/src/main/resources/META-INF/services/io.serverlessworkflow.impl.executors.CallableTask b/impl/openapi/src/main/resources/META-INF/services/io.serverlessworkflow.impl.executors.CallableTask
new file mode 100644
index 00000000..10d85299
--- /dev/null
+++ b/impl/openapi/src/main/resources/META-INF/services/io.serverlessworkflow.impl.executors.CallableTask
@@ -0,0 +1 @@
+io.serverlessworkflow.impl.executors.OpenAPIExecutor
\ No newline at end of file
diff --git a/impl/pom.xml b/impl/pom.xml
index cd3f6076..2499840f 100644
--- a/impl/pom.xml
+++ b/impl/pom.xml
@@ -13,6 +13,7 @@
1.6.0
5.2.3
4.0.0
+ 2.1.34
@@ -36,6 +37,11 @@
serverlessworkflow-impl-jackson
${project.version}
+
+ io.serverlessworkflow
+ serverlessworkflow-impl-openapi
+ ${project.version}
+
net.thisptr
jackson-jq
@@ -47,9 +53,9 @@
${version.com.github.f4b6a3}
- jakarta.ws.rs
- jakarta.ws.rs-api
- ${version.jakarta.ws.rs}
+ jakarta.ws.rs
+ jakarta.ws.rs-api
+ ${version.jakarta.ws.rs}
org.glassfish.jersey.core
@@ -63,6 +69,11 @@
${version.org.glassfish.jersey}
test
+
+ io.swagger.parser.v3
+ swagger-parser
+ ${version.io.swagger.parser.v3}
+
@@ -71,5 +82,6 @@
jackson
jwt-impl
test
+ openapi
\ No newline at end of file
diff --git a/impl/test/pom.xml b/impl/test/pom.xml
index 1009da33..ff62abde 100644
--- a/impl/test/pom.xml
+++ b/impl/test/pom.xml
@@ -24,6 +24,10 @@
io.serverlessworkflow
serverlessworkflow-impl-jackson-jwt
+
+ io.serverlessworkflow
+ serverlessworkflow-impl-openapi
+
org.glassfish.jersey.media
jersey-media-json-jackson
diff --git a/impl/test/src/test/java/io/serverlessworkflow/impl/test/OpenAPIWorkflowDefinitionTest.java b/impl/test/src/test/java/io/serverlessworkflow/impl/test/OpenAPIWorkflowDefinitionTest.java
new file mode 100644
index 00000000..d800d8cd
--- /dev/null
+++ b/impl/test/src/test/java/io/serverlessworkflow/impl/test/OpenAPIWorkflowDefinitionTest.java
@@ -0,0 +1,125 @@
+/*
+ * 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.impl.WorkflowApplication;
+import io.serverlessworkflow.impl.WorkflowException;
+import io.serverlessworkflow.impl.WorkflowModel;
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
+import org.assertj.core.api.Assertions;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+public class OpenAPIWorkflowDefinitionTest {
+
+ private static WorkflowApplication app;
+ private MockWebServer mockServer;
+
+ @BeforeEach
+ public void setUp() throws IOException {
+ mockServer = new MockWebServer();
+ mockServer.start(9999);
+ }
+
+ @AfterEach
+ void tearDown() throws IOException {
+ mockServer.shutdown();
+ }
+
+ @BeforeAll
+ static void setUpApp() {
+ app = WorkflowApplication.builder().build();
+ }
+
+ @Test
+ void testOpenAPIWorkflowExecution() throws IOException {
+
+ WorkflowModel model =
+ app.workflowDefinition(
+ WorkflowReader.readWorkflowFromClasspath(
+ "workflows-samples/openapi/findPetsByStatus.yaml"))
+ .instance(List.of())
+ .start()
+ .join();
+
+ Assertions.assertThat(model.asCollection()).isNotEmpty();
+ Assertions.assertThat(model.asCollection())
+ .allMatch(
+ m -> {
+ Map pet = m.asMap().orElseThrow(RuntimeException::new);
+ return pet.get("status").equals("available");
+ });
+ }
+
+ @Test
+ @DisplayName(
+ "must raise an error for response status codes outside the 200–299 range when redirect is set to false")
+ void testOpenAPIRedirect() {
+ mockServer.enqueue(
+ new MockResponse()
+ .setResponseCode(200)
+ .setBody(
+ """
+ {
+ "openapi": "3.0.3",
+ "info": {
+ "title": "Redirect API",
+ "version": "1.0.0"
+ },
+ "servers": [
+ {
+ "url": "http://localhost:9999"
+ }
+ ],
+ "paths": {
+ "/redirect": {
+ "get": {
+ "operationId": "redirectToDocs",
+ "summary": "Redirects to external documentation",
+ "responses": {
+ "302": {
+ "description": "Redirecting to external documentation"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ """)
+ .setHeader("Content-Type", "application/json"));
+
+ mockServer.enqueue(new MockResponse().setResponseCode(301));
+
+ Assertions.assertThatThrownBy(
+ () ->
+ app.workflowDefinition(
+ WorkflowReader.readWorkflowFromClasspath(
+ "workflows-samples/openapi/findPetsByStatus-redirect.yaml"))
+ .instance(List.of())
+ .start()
+ .join())
+ .hasCauseInstanceOf(WorkflowException.class);
+ }
+}
diff --git a/impl/test/src/test/resources/workflows-samples/openapi/findPetsByStatus-redirect.yaml b/impl/test/src/test/resources/workflows-samples/openapi/findPetsByStatus-redirect.yaml
new file mode 100644
index 00000000..f99d5f9b
--- /dev/null
+++ b/impl/test/src/test/resources/workflows-samples/openapi/findPetsByStatus-redirect.yaml
@@ -0,0 +1,13 @@
+document:
+ dsl: '1.0.1'
+ namespace: test
+ name: openapi-example-with-redirect
+ version: '0.1.0'
+do:
+ - findPet:
+ call: openapi
+ with:
+ document:
+ endpoint: http://localhost:9999
+ operationId: redirectToDocs
+ redirect: false
\ No newline at end of file
diff --git a/impl/test/src/test/resources/workflows-samples/openapi/findPetsByStatus.yaml b/impl/test/src/test/resources/workflows-samples/openapi/findPetsByStatus.yaml
new file mode 100644
index 00000000..eb76395f
--- /dev/null
+++ b/impl/test/src/test/resources/workflows-samples/openapi/findPetsByStatus.yaml
@@ -0,0 +1,14 @@
+document:
+ dsl: '1.0.1'
+ namespace: test
+ name: openapi-example
+ version: '0.1.0'
+do:
+ - findPet:
+ call: openapi
+ with:
+ document:
+ endpoint: https://petstore.swagger.io/v2/swagger.json
+ operationId: findPetsByStatus
+ parameters:
+ status: available
\ No newline at end of file