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 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