diff --git a/impl/openapi/pom.xml b/impl/openapi/pom.xml new file mode 100644 index 00000000..db6e43c0 --- /dev/null +++ b/impl/openapi/pom.xml @@ -0,0 +1,30 @@ + + 4.0.0 + + io.serverlessworkflow + serverlessworkflow-impl + 8.0.0-SNAPSHOT + + serverlessworkflow-impl-openapi + Serverless Workflow :: Impl :: OpenAPI + + + jakarta.ws.rs + jakarta.ws.rs-api + + + io.serverlessworkflow + serverlessworkflow-impl-core + + + io.serverlessworkflow + serverlessworkflow-impl-http + + + io.swagger.parser.v3 + swagger-parser + ${version.io.swagger.parser.v3} + + + diff --git a/impl/openapi/src/main/java/io/serverlessworkflow/impl/executors/openapi/ExpressionURISupplier.java b/impl/openapi/src/main/java/io/serverlessworkflow/impl/executors/openapi/ExpressionURISupplier.java new file mode 100644 index 00000000..e5e256ca --- /dev/null +++ b/impl/openapi/src/main/java/io/serverlessworkflow/impl/executors/openapi/ExpressionURISupplier.java @@ -0,0 +1,35 @@ +/* + * 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.openapi; + +import io.serverlessworkflow.impl.TaskContext; +import io.serverlessworkflow.impl.WorkflowContext; +import io.serverlessworkflow.impl.WorkflowModel; +import io.serverlessworkflow.impl.WorkflowValueResolver; +import java.net.URI; + +class ExpressionURISupplier implements TargetSupplier { + private WorkflowValueResolver resolver; + + ExpressionURISupplier(WorkflowValueResolver resolver) { + this.resolver = resolver; + } + + @Override + public URI apply(WorkflowContext workflow, TaskContext task, WorkflowModel node) { + return URI.create(resolver.apply(workflow, task, node)); + } +} diff --git a/impl/openapi/src/main/java/io/serverlessworkflow/impl/executors/openapi/HttpCallAdapter.java b/impl/openapi/src/main/java/io/serverlessworkflow/impl/executors/openapi/HttpCallAdapter.java new file mode 100644 index 00000000..11607f9c --- /dev/null +++ b/impl/openapi/src/main/java/io/serverlessworkflow/impl/executors/openapi/HttpCallAdapter.java @@ -0,0 +1,211 @@ +/* + * 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.openapi; + +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.TaskTimeout; +import io.serverlessworkflow.api.types.Timeout; +import io.serverlessworkflow.api.types.TimeoutAfter; +import io.serverlessworkflow.api.types.UriTemplate; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.parameters.Parameter; +import java.net.URI; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.Map; + +@SuppressWarnings("rawtypes") +class HttpCallAdapter { + + private ReferenceableAuthenticationPolicy auth; + private Map body; + private String contentType; + private Collection headers; + private String method; + private Collection query; + private boolean redirect; + private URI server; + private URI target; + private Map workflowParams; + + HttpCallAdapter auth(ReferenceableAuthenticationPolicy policy) { + if (policy != null) { + this.auth = policy; + } + return this; + } + + HttpCallAdapter body(Map body) { + this.body = body; + return this; + } + + CallHTTP build() { + CallHTTP callHTTP = new CallHTTP(); + + HTTPArguments httpArgs = new HTTPArguments(); + callHTTP.withWith(httpArgs); + + Endpoint endpoint = new Endpoint(); + httpArgs.withEndpoint(endpoint); + + if (this.auth != null) { + EndpointConfiguration endPointConfig = new EndpointConfiguration(); + endPointConfig.setAuthentication(this.auth); + endpoint.setEndpointConfiguration(endPointConfig); + } + + httpArgs.setRedirect(this.redirect); + httpArgs.setMethod(this.method); + + addHttpHeaders(httpArgs); + addQueryParams(httpArgs); + addBody(httpArgs); + + addTarget(endpoint); + + TaskTimeout taskTimeout = new TaskTimeout(); + Timeout timeout = new Timeout(); + taskTimeout.withTaskTimeoutDefinition(timeout); + TimeoutAfter timeoutAfter = new TimeoutAfter(); + timeout.setAfter(timeoutAfter); + timeoutAfter.withDurationExpression("PT30S"); + callHTTP.setTimeout(taskTimeout); + + return callHTTP; + } + + HttpCallAdapter contentType(String contentType) { + this.contentType = contentType; + return this; + } + + HttpCallAdapter headers(Collection headers) { + this.headers = headers; + return this; + } + + HttpCallAdapter method(String method) { + this.method = method; + return this; + } + + HttpCallAdapter query(Collection query) { + this.query = query; + return this; + } + + HttpCallAdapter redirect(boolean redirect) { + this.redirect = redirect; + return this; + } + + HttpCallAdapter server(String server) { + this.server = URI.create(server); + return this; + } + + HttpCallAdapter target(URI target) { + this.target = target; + return this; + } + + HttpCallAdapter workflowParams(Map workflowParams) { + this.workflowParams = workflowParams; + return this; + } + + private void addBody(HTTPArguments httpArgs) { + Map bodyContent = new LinkedHashMap<>(); + if (!(body == null || body.isEmpty())) { + for (Map.Entry entry : body.entrySet()) { + String name = entry.getKey(); + if (workflowParams.containsKey(name)) { + Object value = workflowParams.get(name); + bodyContent.put(name, value); + } + } + if (!bodyContent.isEmpty()) { + httpArgs.setBody(bodyContent); + } + } + } + + private void addHttpHeaders(HTTPArguments httpArgs) { + if (!(headers == null || headers.isEmpty())) { + Headers hdrs = new Headers(); + HTTPHeaders httpHeaders = new HTTPHeaders(); + hdrs.setHTTPHeaders(httpHeaders); + httpArgs.setHeaders(hdrs); + + for (Parameter p : headers) { + String name = p.getName(); + if (workflowParams.containsKey(name)) { + Object value = workflowParams.get(name); + if (value instanceof String asString) { + httpHeaders.setAdditionalProperty(name, asString); + } else { + throw new IllegalArgumentException("Header parameter " + name + " must be a String"); + } + } + } + } + } + + private void addQueryParams(HTTPArguments httpArgs) { + if (!(query == null || query.isEmpty())) { + Query queryParams = new Query(); + httpArgs.setQuery(queryParams); + HTTPQuery httpQuery = new HTTPQuery(); + queryParams.setHTTPQuery(httpQuery); + + for (Parameter p : query) { + String name = p.getName(); + if (workflowParams.containsKey(name)) { + Object value = workflowParams.get(name); + if (value instanceof String asString) { + httpQuery.setAdditionalProperty(name, asString); + } else if (value instanceof Number asNumber) { + httpQuery.setAdditionalProperty(name, asNumber.toString()); + } else if (value instanceof Boolean asBoolean) { + httpQuery.setAdditionalProperty(name, asBoolean.toString()); + } else if (value instanceof Character asCharacter) { + httpQuery.setAdditionalProperty(name, asCharacter.toString()); + } else { + httpQuery.setAdditionalProperty(name, value.toString()); + } + } + } + } + } + + private void addTarget(Endpoint endpoint) { + if (this.target == null) { + throw new IllegalArgumentException("No Server defined for the OpenAPI operation"); + } + UriTemplate uriTemplate = new UriTemplate(); + uriTemplate.withLiteralUri(this.server.resolve(this.target.getPath())); + endpoint.setUriTemplate(uriTemplate); + } +} diff --git a/impl/openapi/src/main/java/io/serverlessworkflow/impl/executors/openapi/OpenAPIExecutor.java b/impl/openapi/src/main/java/io/serverlessworkflow/impl/executors/openapi/OpenAPIExecutor.java new file mode 100644 index 00000000..95fa1fd0 --- /dev/null +++ b/impl/openapi/src/main/java/io/serverlessworkflow/impl/executors/openapi/OpenAPIExecutor.java @@ -0,0 +1,154 @@ +/* + * 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.openapi; + +import io.serverlessworkflow.api.types.CallHTTP; +import io.serverlessworkflow.api.types.CallOpenAPI; +import io.serverlessworkflow.api.types.Endpoint; +import io.serverlessworkflow.api.types.EndpointUri; +import io.serverlessworkflow.api.types.TaskBase; +import io.serverlessworkflow.api.types.UriTemplate; +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.WorkflowException; +import io.serverlessworkflow.impl.WorkflowModel; +import io.serverlessworkflow.impl.executors.CallableTask; +import io.serverlessworkflow.impl.executors.http.HttpExecutor; +import io.serverlessworkflow.impl.expressions.ExpressionDescriptor; +import io.serverlessworkflow.impl.expressions.ExpressionFactory; +import io.serverlessworkflow.impl.resources.ResourceLoader; +import jakarta.ws.rs.core.UriBuilder; +import java.net.URI; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; + +public class OpenAPIExecutor implements CallableTask { + + private CallOpenAPI task; + private Workflow workflow; + private WorkflowApplication application; + private TargetSupplier targetSupplier; + + private ResourceLoader resourceLoader; + + @Override + public boolean accept(Class clazz) { + return clazz.equals(CallOpenAPI.class); + } + + @Override + public CompletableFuture apply( + WorkflowContext workflowContext, TaskContext taskContext, WorkflowModel input) { + String operationId = task.getWith().getOperationId(); + URI openAPIEndpoint = targetSupplier.apply(workflowContext, taskContext, input); + OpenAPIProcessor processor = new OpenAPIProcessor(operationId, openAPIEndpoint); + OperationDefinition operation = processor.parse(); + + OperationPathResolver pathResolver = + new OperationPathResolver( + operation.getPath(), + application, + task.getWith().getParameters().getAdditionalProperties()); + + return CompletableFuture.supplyAsync( + () -> { + HttpCallAdapter httpCallAdapter = + new HttpCallAdapter() + .auth(task.getWith().getAuthentication()) + .body(operation.getBody()) + .contentType(operation.getContentType()) + .headers( + operation.getParameters().stream() + .filter(p -> "header".equals(p.getIn())) + .collect(Collectors.toUnmodifiableSet())) + .method(operation.getMethod()) + .query( + operation.getParameters().stream() + .filter(p -> "query".equals(p.getIn())) + .collect(Collectors.toUnmodifiableSet())) + .redirect(task.getWith().isRedirect()) + .target(pathResolver.resolve(workflowContext, taskContext, input)) + .workflowParams(task.getWith().getParameters().getAdditionalProperties()); + + WorkflowException workflowException = null; + + for (var server : operation.getServers()) { + CallHTTP callHTTP = httpCallAdapter.server(server).build(); + HttpExecutor executor = new HttpExecutor(); + executor.init(callHTTP, workflow, application, resourceLoader); + + try { + return executor.apply(workflowContext, taskContext, input).get(); + } catch (WorkflowException e) { + workflowException = e; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + throw workflowException; // if we there, we failed all servers and ex is not null + }, + workflowContext.definition().application().executorService()); + } + + @Override + public void init( + CallOpenAPI task, + Workflow workflow, + WorkflowApplication application, + ResourceLoader resourceLoader) { + this.task = task; + this.workflow = workflow; + this.application = application; + this.targetSupplier = + getTargetSupplier( + task.getWith().getDocument().getEndpoint(), application.expressionFactory()); + this.resourceLoader = resourceLoader; + } + + private TargetSupplier getTargetSupplier(Endpoint endpoint, ExpressionFactory expressionFactory) { + if (endpoint.getEndpointConfiguration() != null) { + EndpointUri uri = endpoint.getEndpointConfiguration().getUri(); + if (uri.getLiteralEndpointURI() != null) { + return getURISupplier(uri.getLiteralEndpointURI()); + } else if (uri.getExpressionEndpointURI() != null) { + return new ExpressionURISupplier( + expressionFactory.resolveString( + ExpressionDescriptor.from(uri.getExpressionEndpointURI()))); + } + } else if (endpoint.getRuntimeExpression() != null) { + return new ExpressionURISupplier( + expressionFactory.resolveString( + ExpressionDescriptor.from(endpoint.getRuntimeExpression()))); + } else if (endpoint.getUriTemplate() != null) { + return getURISupplier(endpoint.getUriTemplate()); + } + throw new IllegalArgumentException("Invalid endpoint definition " + endpoint); + } + + private TargetSupplier getURISupplier(UriTemplate template) { + if (template.getLiteralUri() != null) { + return (w, t, n) -> template.getLiteralUri(); + } else if (template.getLiteralUriTemplate() != null) { + return (w, t, n) -> + UriBuilder.fromUri(template.getLiteralUriTemplate()) + .resolveTemplates(n.asMap().orElseThrow(), false) + .build(); + } + throw new IllegalArgumentException("Invalid uri template definition " + template); + } +} diff --git a/impl/openapi/src/main/java/io/serverlessworkflow/impl/executors/openapi/OpenAPIProcessor.java b/impl/openapi/src/main/java/io/serverlessworkflow/impl/executors/openapi/OpenAPIProcessor.java new file mode 100644 index 00000000..2e05e5fe --- /dev/null +++ b/impl/openapi/src/main/java/io/serverlessworkflow/impl/executors/openapi/OpenAPIProcessor.java @@ -0,0 +1,97 @@ +/* + * 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.openapi; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.PathItem; +import io.swagger.v3.parser.OpenAPIV3Parser; +import io.swagger.v3.parser.core.models.ParseOptions; +import java.net.URI; +import java.util.List; +import java.util.Set; + +class OpenAPIProcessor { + + private final String operationId; + private final URI openAPIEndpoint; + + OpenAPIProcessor(String operationId, URI openAPIEndpoint) { + this.operationId = operationId; + this.openAPIEndpoint = openAPIEndpoint; + } + + OperationDefinition parse() { + OpenAPIV3Parser parser = new OpenAPIV3Parser(); + ParseOptions opts = new ParseOptions(); + opts.setResolve(true); + opts.setResolveFully(false); + + var result = parser.readLocation(openAPIEndpoint.toString(), List.of(), opts); + var openapi = result.getOpenAPI(); + return getOperation(openapi); + } + + OperationDefinition getOperation(OpenAPI openAPI) { + if (openAPI == null || openAPI.getPaths() == null) { + throw new IllegalArgumentException("Invalid OpenAPI document"); + } + + Set paths = openAPI.getPaths().keySet(); + + for (String path : paths) { + PathItem pathItem = openAPI.getPaths().get(path); + OperationAndMethod operationAndMethod = findInPathItem(pathItem, operationId); + if (operationAndMethod != null) { + return new OperationDefinition( + openAPI, operationAndMethod.operation, path, operationAndMethod.method); + } + } + throw new IllegalArgumentException( + "No operation with id '" + operationId + "' found in OpenAPI document"); + } + + private OperationAndMethod findInPathItem(PathItem pathItem, String operationId) { + if (pathItem == null) { + return null; + } + + if (matches(pathItem.getGet(), operationId)) + return new OperationAndMethod(pathItem.getGet(), "GET"); + if (matches(pathItem.getPost(), operationId)) + return new OperationAndMethod(pathItem.getPost(), "POST"); + if (matches(pathItem.getPut(), operationId)) + return new OperationAndMethod(pathItem.getPut(), "PUT"); + if (matches(pathItem.getDelete(), operationId)) + return new OperationAndMethod(pathItem.getDelete(), "DELETE"); + if (matches(pathItem.getPatch(), operationId)) + return new OperationAndMethod(pathItem.getPatch(), "PATCH"); + if (matches(pathItem.getHead(), operationId)) + return new OperationAndMethod(pathItem.getHead(), "HEAD"); + if (matches(pathItem.getOptions(), operationId)) + return new OperationAndMethod(pathItem.getOptions(), "OPTIONS"); + if (matches(pathItem.getTrace(), operationId)) + return new OperationAndMethod(pathItem.getTrace(), "TRACE"); + + return null; + } + + private boolean matches(Operation op, String operationId) { + return op != null && operationId.equals(op.getOperationId()); + } + + private record OperationAndMethod(Operation operation, String method) {} +} diff --git a/impl/openapi/src/main/java/io/serverlessworkflow/impl/executors/openapi/OperationDefinition.java b/impl/openapi/src/main/java/io/serverlessworkflow/impl/executors/openapi/OperationDefinition.java new file mode 100644 index 00000000..0ffc36b4 --- /dev/null +++ b/impl/openapi/src/main/java/io/serverlessworkflow/impl/executors/openapi/OperationDefinition.java @@ -0,0 +1,132 @@ +/* + * 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.openapi; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.media.Content; +import io.swagger.v3.oas.models.media.MediaType; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.parameters.Parameter; +import io.swagger.v3.oas.models.responses.ApiResponse; +import io.swagger.v3.oas.models.servers.Server; +import java.util.List; +import java.util.Map; + +class OperationDefinition { + private final Operation operation; + private final String method; + private final OpenAPI openAPI; + private final String path; + + OperationDefinition(OpenAPI openAPI, Operation operation, String path, String method) { + this.openAPI = openAPI; + this.operation = operation; + this.path = path; + this.method = method; + } + + String getMethod() { + return method; + } + + String getPath() { + return path; + } + + Operation getOperation() { + return operation; + } + + List getServers() { + return openAPI.getServers().stream().map(Server::getUrl).toList(); + } + + List getParameters() { + if (operation.getParameters() == null) { + return List.of(); + } + return operation.getParameters(); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + Map getBody() { + if (operation.getRequestBody() != null && operation.getRequestBody().getContent() != null) { + Content content = operation.getRequestBody().getContent(); + if (content.containsKey("application/json")) { + MediaType mt = content.get("application/json"); + if (mt.getSchema().get$ref() != null && !mt.getSchema().get$ref().isEmpty()) { + Schema schema = resolveSchema(mt.getSchema().get$ref()); + return schema.getProperties(); + } else if (mt.getSchema().getProperties() != null) { + return mt.getSchema().getProperties(); + } else { + throw new IllegalArgumentException( + "Can't resolve schema for request body of operation " + operation.getOperationId()); + } + } else { + throw new IllegalArgumentException("Only 'application/json' content type is supported"); + } + } + return Map.of(); + } + + String getContentType() { + String method = getMethod().toUpperCase(); + + if (method.equals("POST") || method.equals("PUT") || method.equals("PATCH")) { + if (operation.getRequestBody() != null && operation.getRequestBody().getContent() != null) { + Content content = operation.getRequestBody().getContent(); + if (!content.isEmpty()) { + return content.keySet().iterator().next(); + } + } + } + + if (operation.getResponses() != null) { + for (String code : new String[] {"200", "201", "204"}) { + ApiResponse resp = operation.getResponses().get(code); + if (resp != null && resp.getContent() != null && !resp.getContent().isEmpty()) { + return resp.getContent().keySet().iterator().next(); + } + } + for (Map.Entry e : operation.getResponses().entrySet()) { + Content content = e.getValue().getContent(); + if (content != null && !content.isEmpty()) { + return content.keySet().iterator().next(); + } + } + } + + throw new IllegalStateException( + "No content type found for operation " + operation.getOperationId() + " [" + method + "]"); + } + + Schema resolveSchema(String ref) { + if (ref == null || !ref.startsWith("#/components/schemas/")) { + throw new IllegalArgumentException("Unsupported $ref format: " + ref); + } + String name = ref.substring("#/components/schemas/".length()); + if (openAPI.getComponents() == null || openAPI.getComponents().getSchemas() == null) { + throw new IllegalStateException("No components/schemas found in OpenAPI"); + } + Schema schema = openAPI.getComponents().getSchemas().get(name); + if (schema == null) { + throw new IllegalArgumentException("Schema not found: " + name); + } + return schema; + } +} diff --git a/impl/openapi/src/main/java/io/serverlessworkflow/impl/executors/openapi/OperationPathResolver.java b/impl/openapi/src/main/java/io/serverlessworkflow/impl/executors/openapi/OperationPathResolver.java new file mode 100644 index 00000000..d9c808e4 --- /dev/null +++ b/impl/openapi/src/main/java/io/serverlessworkflow/impl/executors/openapi/OperationPathResolver.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.executors.openapi; + +import io.serverlessworkflow.impl.TaskContext; +import io.serverlessworkflow.impl.WorkflowApplication; +import io.serverlessworkflow.impl.WorkflowContext; +import io.serverlessworkflow.impl.WorkflowModel; +import io.serverlessworkflow.impl.WorkflowValueResolver; +import io.serverlessworkflow.impl.expressions.ExpressionDescriptor; +import jakarta.ws.rs.core.UriBuilder; +import java.net.URI; +import java.util.Map; + +class OperationPathResolver { + + private final Map args; + private final String path; + private WorkflowApplication application; + + OperationPathResolver(String path, WorkflowApplication application, Map args) { + this.path = path; + this.args = args; + this.application = application; + } + + URI resolve(WorkflowContext workflow, TaskContext task, WorkflowModel model) { + WorkflowValueResolver> asMap = + application.expressionFactory().resolveMap(ExpressionDescriptor.object(args)); + return UriBuilder.fromUri(path) + .resolveTemplates(asMap.apply(workflow, task, model), false) + .build(); + } +} diff --git a/impl/openapi/src/main/java/io/serverlessworkflow/impl/executors/openapi/TargetSupplier.java b/impl/openapi/src/main/java/io/serverlessworkflow/impl/executors/openapi/TargetSupplier.java new file mode 100644 index 00000000..553ba7a1 --- /dev/null +++ b/impl/openapi/src/main/java/io/serverlessworkflow/impl/executors/openapi/TargetSupplier.java @@ -0,0 +1,25 @@ +/* + * 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.openapi; + +import io.serverlessworkflow.impl.TaskContext; +import io.serverlessworkflow.impl.WorkflowContext; +import io.serverlessworkflow.impl.WorkflowModel; +import java.net.URI; + +interface TargetSupplier { + URI apply(WorkflowContext workflow, TaskContext taskContext, WorkflowModel input); +} 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..e394143c --- /dev/null +++ b/impl/openapi/src/main/resources/META-INF/services/io.serverlessworkflow.impl.executors.CallableTask @@ -0,0 +1 @@ +io.serverlessworkflow.impl.executors.openapi.OpenAPIExecutor diff --git a/impl/pom.xml b/impl/pom.xml index cd3f6076..00f74bdc 100644 --- a/impl/pom.xml +++ b/impl/pom.xml @@ -36,6 +36,11 @@ serverlessworkflow-impl-jackson ${project.version} + + io.serverlessworkflow + serverlessworkflow-impl-openapi + ${project.version} + net.thisptr jackson-jq @@ -70,6 +75,7 @@ core jackson jwt-impl + openapi test - \ No newline at end of file + diff --git a/impl/test/pom.xml b/impl/test/pom.xml index 1009da33..809ccf0a 100644 --- a/impl/test/pom.xml +++ b/impl/test/pom.xml @@ -28,6 +28,10 @@ org.glassfish.jersey.media jersey-media-json-jackson + + io.serverlessworkflow + serverlessworkflow-impl-openapi + org.glassfish.jersey.core jersey-client @@ -62,6 +66,14 @@ + + + src/test/resources + + **/* + + + maven-jar-plugin diff --git a/impl/test/src/test/java/io/serverlessworkflow/impl/test/AccessTokenProvider.java b/impl/test/src/test/java/io/serverlessworkflow/impl/test/AccessTokenProvider.java new file mode 100644 index 00000000..6d8570ec --- /dev/null +++ b/impl/test/src/test/java/io/serverlessworkflow/impl/test/AccessTokenProvider.java @@ -0,0 +1,57 @@ +/* + * 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 com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Base64; +import java.util.Map; + +public class AccessTokenProvider { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + public static String fakeAccessToken() throws Exception { + long now = Instant.now().getEpochSecond(); + return fakeJwt( + Map.of( + "iss", "http://localhost:8888/realms/test-realm", + "aud", "account", + "sub", "test-subject", + "azp", "serverless-workflow", + "scope", "profile email", + "exp", now + 3600, + "iat", now)); + } + + public static String fakeJwt(Map payload) throws Exception { + String headerJson = + MAPPER.writeValueAsString( + Map.of( + "alg", "RS256", + "typ", "Bearer", + "kid", "test")); + String payloadJson = MAPPER.writeValueAsString(payload); + return b64Url(headerJson) + "." + b64Url(payloadJson) + ".sig"; + } + + private static String b64Url(String s) { + return Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(s.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/impl/test/src/test/java/io/serverlessworkflow/impl/test/OAuthHTTPWorkflowDefinitionTest.java b/impl/test/src/test/java/io/serverlessworkflow/impl/test/OAuthHTTPWorkflowDefinitionTest.java index 157b894e..91a97d10 100644 --- a/impl/test/src/test/java/io/serverlessworkflow/impl/test/OAuthHTTPWorkflowDefinitionTest.java +++ b/impl/test/src/test/java/io/serverlessworkflow/impl/test/OAuthHTTPWorkflowDefinitionTest.java @@ -16,6 +16,7 @@ package io.serverlessworkflow.impl.test; import static io.serverlessworkflow.api.WorkflowReader.readWorkflowFromClasspath; +import static io.serverlessworkflow.impl.test.AccessTokenProvider.fakeAccessToken; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -23,9 +24,6 @@ import io.serverlessworkflow.api.types.Workflow; import io.serverlessworkflow.impl.WorkflowApplication; import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.time.Instant; -import java.util.Base64; import java.util.Map; import okhttp3.OkHttpClient; import okhttp3.mockwebserver.MockResponse; @@ -853,34 +851,4 @@ public void testOAuthJSONClientCredentialsParamsNoEndpointWorkflowExecution() th assertEquals("/hello", petRequest.getPath()); assertEquals("Bearer " + jwt, petRequest.getHeader("Authorization")); } - - public static String fakeJwt(Map payload) throws Exception { - String headerJson = - MAPPER.writeValueAsString( - Map.of( - "alg", "RS256", - "typ", "Bearer", - "kid", "test")); - String payloadJson = MAPPER.writeValueAsString(payload); - return b64Url(headerJson) + "." + b64Url(payloadJson) + ".sig"; - } - - private static String b64Url(String s) { - return Base64.getUrlEncoder() - .withoutPadding() - .encodeToString(s.getBytes(StandardCharsets.UTF_8)); - } - - public static String fakeAccessToken() throws Exception { - long now = Instant.now().getEpochSecond(); - return fakeJwt( - Map.of( - "iss", "http://localhost:8888/realms/test-realm", - "aud", "account", - "sub", "test-subject", - "azp", "serverless-workflow", - "scope", "profile email", - "exp", now + 3600, - "iat", now)); - } } diff --git a/impl/test/src/test/java/io/serverlessworkflow/impl/test/OpenAPITest.java b/impl/test/src/test/java/io/serverlessworkflow/impl/test/OpenAPITest.java new file mode 100644 index 00000000..20a86c9b --- /dev/null +++ b/impl/test/src/test/java/io/serverlessworkflow/impl/test/OpenAPITest.java @@ -0,0 +1,319 @@ +/* + * 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.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.serverlessworkflow.api.types.Workflow; +import io.serverlessworkflow.impl.WorkflowApplication; +import java.io.IOException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import okhttp3.OkHttpClient; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class OpenAPITest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private MockWebServer authServer; + private MockWebServer openApiServer; + private MockWebServer restServer; + + private OkHttpClient httpClient; + + private static String PROJECT_JSON_SUCCESS = + """ + { + "success": true, + "data": { + "id": 55504, + "name": "CRM", + "code": "crm-20251111", + "ownerId": 12345, + "members": [ + 12345, + 67890 + ], + "created_at": "2025-09-20T00:58:50.170784Z" + } + } + """; + + private static String PROJECT_JSON_FALSE = + """ + { + "success": false, + "error": { + "code": "PROJECT_CONFLICT", + "message": "A project with the code "crm-2025" already exists.", + "details": null + } + } + """; + + private static String PROJECT_GET_JSON_POSITIVE = + """ + { + "success": true, + "data": { + "id": 40099, + "name": "Severus Calix", + "email": "severus.calix@hive-terra.example.com" + }, + "meta": { + "request_id": "req_terra123def456", + "timestamp": "999.M41-01-20T12:00:00Z" + } + } + """; + + @BeforeEach + void setUp() throws IOException { + authServer = new MockWebServer(); + authServer.start(8888); + + openApiServer = new MockWebServer(); + openApiServer.start(8887); + + restServer = new MockWebServer(); + restServer.start(8886); + + httpClient = new OkHttpClient(); + } + + @AfterEach + void tearDown() throws IOException { + authServer.shutdown(); + openApiServer.shutdown(); + restServer.shutdown(); + } + + @Test + public void testOpenAPIBearerQueryInlinedBodyWithPositiveResponse() throws Exception { + Workflow workflow = + readWorkflowFromClasspath("workflows-samples/openapi/project-post-positive.yaml"); + + URL url = this.getClass().getResource("/schema/openapi/openapi.yaml"); + + Path workflowPath = Path.of(url.getPath()); + String yaml = Files.readString(workflowPath, StandardCharsets.UTF_8); + + openApiServer.enqueue( + new MockResponse() + .setBody(yaml) + .setHeader("Content-Type", "application/yaml") + .setResponseCode(200)); + + restServer.enqueue( + new MockResponse() + .setBody(PROJECT_JSON_SUCCESS) + .setHeader("Content-Type", "application/json") + .setResponseCode(201)); + + Map result; + + try (WorkflowApplication app = WorkflowApplication.builder().build()) { + result = + app.workflowDefinition(workflow).instance(Map.of()).start().get().asMap().orElseThrow(); + } catch (Exception e) { + throw new RuntimeException("Workflow execution failed", e); + } + + RecordedRequest restRequest = restServer.takeRequest(); + assertEquals("POST", restRequest.getMethod()); + assertTrue(restRequest.getPath().startsWith("/projects?")); + assertTrue(restRequest.getPath().contains("notifyMembers=true")); + assertTrue(restRequest.getPath().contains("validateOnly=false")); + assertTrue(restRequest.getPath().contains("lang=en")); + assertEquals("application/json", restRequest.getHeader("Content-Type")); + assertEquals("Bearer eyJhbnNpc2l0b3IuYm9sdXMubWFnbnVz", restRequest.getHeader("Authorization")); + + assertEquals(true, result.get("success")); + Map data = (Map) result.get("data"); + assertEquals(55504, data.get("id")); + assertEquals("CRM", data.get("name")); + assertEquals("crm-20251111", data.get("code")); + assertEquals(12345, data.get("ownerId")); + assertEquals("2025-09-20T00:58:50.170784Z", data.get("created_at")); + assertTrue(data.containsKey("members")); + List members = (List) data.get("members"); + assertEquals(2, members.size()); + assertEquals(12345, members.get(0)); + assertEquals(67890, members.get(1)); + } + + @Test + public void testOpenAPIBearerQueryInlinedBodyWithNegativeResponse() throws Exception { + Workflow workflow = + readWorkflowFromClasspath("workflows-samples/openapi/project-post-positive.yaml"); + + URL url = this.getClass().getResource("/schema/openapi/openapi.yaml"); + + Path workflowPath = Path.of(url.getPath()); + String yaml = Files.readString(workflowPath, StandardCharsets.UTF_8); + + openApiServer.enqueue( + new MockResponse() + .setBody(yaml) + .setHeader("Content-Type", "application/yaml") + .setResponseCode(200)); + + restServer.enqueue( + new MockResponse() + .setBody(PROJECT_JSON_FALSE) + .setHeader("Content-Type", "application/json") + .setResponseCode(409)); + + Map result; + + Exception exception = null; + + try (WorkflowApplication app = WorkflowApplication.builder().build()) { + result = + app.workflowDefinition(workflow).instance(Map.of()).start().get().asMap().orElseThrow(); + } catch (Exception e) { + exception = e; + } + + RecordedRequest restRequest = restServer.takeRequest(); + assertEquals("POST", restRequest.getMethod()); + assertTrue(restRequest.getPath().startsWith("/projects?")); + assertTrue(restRequest.getPath().contains("notifyMembers=true")); + assertTrue(restRequest.getPath().contains("validateOnly=false")); + assertTrue(restRequest.getPath().contains("lang=en")); + assertEquals("application/json", restRequest.getHeader("Content-Type")); + assertEquals("Bearer eyJhbnNpc2l0b3IuYm9sdXMubWFnbnVz", restRequest.getHeader("Authorization")); + + assertNotNull(exception); + assertTrue(exception.getMessage().contains("status=409")); + assertTrue(exception.getMessage().contains("title=HTTP 409 Client Error")); + } + + @Test + public void testOpenAPIGetWithPositiveResponse() throws Exception { + Workflow workflow = + readWorkflowFromClasspath("workflows-samples/openapi/get-user-get-request.yaml"); + + URL url = this.getClass().getResource("/schema/openapi/openapi.yaml"); + + Path workflowPath = Path.of(url.getPath()); + String yaml = Files.readString(workflowPath, StandardCharsets.UTF_8); + + openApiServer.enqueue( + new MockResponse() + .setBody(yaml) + .setHeader("Content-Type", "application/yaml") + .setResponseCode(200)); + + restServer.enqueue( + new MockResponse() + .setBody(PROJECT_GET_JSON_POSITIVE) + .setHeader("Content-Type", "application/json") + .setResponseCode(200)); + + Map result; + try (WorkflowApplication app = WorkflowApplication.builder().build()) { + result = + app.workflowDefinition(workflow).instance(Map.of()).start().get().asMap().orElseThrow(); + } catch (Exception e) { + throw new RuntimeException("Workflow execution failed", e); + } + + RecordedRequest restRequest = restServer.takeRequest(); + assertEquals("GET", restRequest.getMethod()); + assertTrue(restRequest.getPath().startsWith("/users/40099?")); + + assertTrue(result.containsKey("data")); + Map data = (Map) result.get("data"); + assertEquals(40099, data.get("id")); + assertEquals("Severus Calix", data.get("name")); + assertEquals("severus.calix@hive-terra.example.com", data.get("email")); + } + + @Test + public void testOpenAPIGetWithPositiveResponseAndVars() throws Exception { + Workflow workflow = + readWorkflowFromClasspath("workflows-samples/openapi/get-user-get-request-vars.yaml"); + + URL url = this.getClass().getResource("/schema/openapi/openapi.yaml"); + + Path workflowPath = Path.of(url.getPath()); + String yaml = Files.readString(workflowPath, StandardCharsets.UTF_8); + + openApiServer.enqueue( + new MockResponse() + .setBody(yaml) + .setHeader("Content-Type", "application/yaml") + .setResponseCode(200)); + + restServer.enqueue( + new MockResponse() + .setBody(PROJECT_GET_JSON_POSITIVE) + .setHeader("Content-Type", "application/json") + .setResponseCode(200)); + + Map result; + Map params = + Map.of( + "userId", + 40099, + "id", + "id", + "name", + "name", + "email", + "email", + "include_deleted", + "false", + "lang", + "en", + "format", + "full", + "limit", + 20); + + try (WorkflowApplication app = WorkflowApplication.builder().build()) { + result = + app.workflowDefinition(workflow).instance(params).start().get().asMap().orElseThrow(); + } catch (Exception e) { + throw new RuntimeException("Workflow execution failed", e); + } + + RecordedRequest restRequest = restServer.takeRequest(); + assertEquals("GET", restRequest.getMethod()); + assertTrue(restRequest.getPath().startsWith("/users/40099?")); + + assertTrue(result.containsKey("data")); + Map data = (Map) result.get("data"); + assertEquals(40099, data.get("id")); + assertEquals("Severus Calix", data.get("name")); + assertEquals("severus.calix@hive-terra.example.com", data.get("email")); + } +} diff --git a/impl/test/src/test/java/io/serverlessworkflow/impl/test/OpenIDCHTTPWorkflowDefinitionTest.java b/impl/test/src/test/java/io/serverlessworkflow/impl/test/OpenIDCHTTPWorkflowDefinitionTest.java index f3e34f36..cbc60fc9 100644 --- a/impl/test/src/test/java/io/serverlessworkflow/impl/test/OpenIDCHTTPWorkflowDefinitionTest.java +++ b/impl/test/src/test/java/io/serverlessworkflow/impl/test/OpenIDCHTTPWorkflowDefinitionTest.java @@ -16,7 +16,7 @@ package io.serverlessworkflow.impl.test; import static io.serverlessworkflow.api.WorkflowReader.readWorkflowFromClasspath; -import static io.serverlessworkflow.impl.test.OAuthHTTPWorkflowDefinitionTest.fakeAccessToken; +import static io.serverlessworkflow.impl.test.AccessTokenProvider.fakeAccessToken; import static org.junit.Assert.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -99,7 +99,6 @@ public void testOpenIDCClientSecretPostPasswordWorkflowExecution() throws Except Workflow workflow = readWorkflowFromClasspath("openidcClientSecretPostPasswordHttpCall.yaml"); Map result; - System.err.println("START"); try (WorkflowApplication app = WorkflowApplication.builder().build()) { result = app.workflowDefinition(workflow).instance(Map.of()).start().get().asMap().orElseThrow(); diff --git a/impl/test/src/test/resources/schema/openapi/openapi.yaml b/impl/test/src/test/resources/schema/openapi/openapi.yaml new file mode 100644 index 00000000..b0c36ba3 --- /dev/null +++ b/impl/test/src/test/resources/schema/openapi/openapi.yaml @@ -0,0 +1,423 @@ +openapi: 3.0.3 +info: + title: User Management API + description: API for managing users in the Imperium + version: 1.0.0 + contact: + name: Administratum Support + email: support@imperium.example.com + +servers: + - url: http://127.0.0.1:8886/api/v1 + description: Segmentum Solar Production Server + +paths: + /users/{userId}: + get: + summary: Get user information + description: Returns detailed information about a user by their ID + operationId: getUserById + tags: + - Users + parameters: + - name: userId + in: path + required: true + description: Unique user identifier + schema: + type: integer + format: int64 + minimum: 1 + example: 40099 + - name: fields + in: query + required: true + description: List of fields to include in the response + schema: + type: array + items: + type: string + enum: [id, name, email, phone, address, created_at, updated_at] + minItems: 1 + style: form + explode: false + example: ["id", "name", "email"] + - name: include_deleted + in: query + required: false + description: Whether to include purged users in the result + schema: + type: boolean + default: false + example: false + - name: format + in: query + required: false + description: Format of the returned data + schema: + type: string + enum: [full, summary, minimal] + default: full + example: full + - name: lang + in: query + required: false + description: Response localization language + schema: + type: string + pattern: '^[a-z]{2}(-[A-Z]{2})?$' + default: gothic + example: gothic + - name: limit + in: query + required: false + description: Maximum number of related records + schema: + type: integer + minimum: 1 + maximum: 100 + default: 10 + example: 20 + responses: + '200': + description: Successful response with user information + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + data: + $ref: '#/components/schemas/User' + meta: + type: object + properties: + request_id: + type: string + example: "req_terra123def456" + timestamp: + type: string + format: date-time + example: "999.M41-01-20T12:00:00Z" + '400': + $ref: '#/components/responses/BadRequest' + '404': + description: User not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /users: + post: + summary: Create a new user + description: Creates a user record based on provided data + operationId: createUser + tags: + - Users + requestBody: + required: true + description: JSON object with new user data + content: + application/json: + schema: + $ref: '#/components/schemas/NewUser' + example: + name: "Severus Calix" + email: "severus.calix@hive-terra.example.com" + phone: "+41-999-123-4567" + address: + city: "Hive Terra" + street: "Sector Primus, Spire 1" + postal_code: "HX-009" + responses: + '201': + description: User successfully created + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + data: + $ref: '#/components/schemas/User' + '400': + $ref: '#/components/responses/BadRequest' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /projects: + post: + summary: Create a new project + description: Creates a project; supports query flags for validation and notifications mode + operationId: addProject + tags: [Projects] + + parameters: + - name: validateOnly + in: query + required: false + description: If true — perform validation only without saving + schema: { type: boolean, default: false } + example: false + - name: notifyMembers + in: query + required: false + description: Whether to send notifications to members after creation + schema: { type: boolean, default: true } + example: true + - name: lang + in: query + required: false + description: Locale for notification/message texts + schema: + type: string + pattern: '^[a-z]{2}(-[A-Z]{2})?$' + default: gothic + example: gothic + + - name: Authorization + in: header + required: true + description: Bearer authorization token + schema: + type: string + example: "Bearer eyJhbnNpc2l0b3IuYm9sdXMubWFnbnVz..." + - name: X-Request-ID + in: header + required: false + description: Unique request identifier for tracing + schema: + type: string + example: "req_proj_terra123" + - name: X-Api-Version + in: header + required: false + description: Explicit API version indication + schema: + type: string + example: "1.0" + + requestBody: + required: true + description: JSON object with new project data + content: + application/json: + schema: + type: object + required: [name] + properties: + name: + type: string + example: "Cathedral Archive" + code: + type: string + example: "archive-999" + description: + type: string + example: "Data-vault for the Ordo Hereticus" + ownerId: + type: integer + format: int64 + example: 88888 + members: + type: array + items: + type: integer + format: int64 + example: [11111, 22222] + example: + name: "Cathedral Archive" + code: "archive-999" + description: "Data-vault for the Ordo Hereticus" + ownerId: 88888 + members: [11111, 22222] + + responses: + '201': + description: Project successfully created + content: + application/json: + schema: + type: object + properties: + success: { type: boolean, example: true } + data: + type: object + required: [id, name] + properties: + id: { type: integer, format: int64, example: 99901 } + name: { type: string, example: "Cathedral Archive" } + code: { type: string, example: "archive-999" } + ownerId: { type: integer, format: int64, example: 88888 } + members: + type: array + items: { type: integer, format: int64 } + example: [11111, 22222] + created_at: { type: string, format: date-time, example: "999.M41-09-18T12:00:00Z" } + meta: + type: object + properties: + request_id: { type: string, example: "req_proj_terra123" } + timestamp: { type: string, format: date-time, example: "999.M41-09-18T12:00:00Z" } + '400': + description: Invalid request + content: + application/json: + schema: + type: object + properties: + success: { type: boolean, example: false } + error: + type: object + properties: + code: { type: string, example: "INVALID_PARAMETERS" } + message: { type: string, example: "The field name is required" } + details: + type: array + items: { type: string } + example: ["name: must not be blank"] + '409': + description: Conflict — a project with the same code already exists + content: + application/json: + schema: + type: object + properties: + success: { type: boolean, example: false } + error: + type: object + properties: + code: { type: string, example: "PROJECT_CONFLICT" } + message: { type: string, example: "Project with code 'archive-999' already exists" } + '500': + description: Internal server error + content: + application/json: + schema: + type: object + properties: + success: { type: boolean, example: false } + error: + type: object + properties: + code: { type: string, example: "INTERNAL_SERVER_ERROR" } + message: { type: string, example: "An internal server error occurred" } + request_id: { type: string, example: "req_err_proj_001" } + +components: + schemas: + User: + type: object + required: [id, name, email] + properties: + id: + type: integer + format: int64 + description: Unique user identifier + example: 40099 + name: + type: string + description: Full user name + example: "Severus Calix" + email: + type: string + format: email + example: "severus.calix@hive-terra.example.com" + phone: + type: string + example: "+41-999-123-4567" + address: + type: object + properties: + city: + type: string + example: "Hive Terra" + street: + type: string + example: "Sector Primus, Spire 1" + postal_code: + type: string + example: "HX-009" + created_at: + type: string + format: date-time + example: "999.M41-01-15T10:30:00Z" + updated_at: + type: string + format: date-time + example: "999.M41-12-20T14:45:30Z" + + NewUser: + type: object + required: [name, email] + properties: + name: + type: string + example: "Severus Calix" + email: + type: string + format: email + example: "severus.calix@hive-terra.example.com" + phone: + type: string + example: "+41-999-123-4567" + address: + type: object + properties: + city: + type: string + example: "Hive Terra" + street: + type: string + example: "Sector Primus, Spire 1" + postal_code: + type: string + example: "HX-009" + + Error: + type: object + properties: + success: + type: boolean + example: false + error: + type: object + properties: + code: + type: string + message: + type: string + details: + type: array + items: + type: string + + responses: + BadRequest: + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + success: false + error: + code: "INVALID_PARAMETERS" + message: "Invalid user data" + details: + - "Email has an incorrect format" diff --git a/impl/test/src/test/resources/workflows-samples/openapi/get-user-get-request-vars.yaml b/impl/test/src/test/resources/workflows-samples/openapi/get-user-get-request-vars.yaml new file mode 100644 index 00000000..af032554 --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/openapi/get-user-get-request-vars.yaml @@ -0,0 +1,22 @@ +document: + dsl: '1.0.1' + namespace: test + name: openapi-example + version: '0.1.0' +do: + - getUser: + call: openapi + with: + document: + endpoint: http://127.0.0.1:8887/schema.yaml + operationId: getUserById + parameters: + userId: ${ .userId } + fields: + - ${ .id } + - ${ .name } + - ${ .email } + include_deleted: ${ .include_deleted } + format: ${ .format } + lang: ${ .lang } + limit: ${ .limit } \ No newline at end of file diff --git a/impl/test/src/test/resources/workflows-samples/openapi/get-user-get-request.yaml b/impl/test/src/test/resources/workflows-samples/openapi/get-user-get-request.yaml new file mode 100644 index 00000000..d0680b29 --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/openapi/get-user-get-request.yaml @@ -0,0 +1,22 @@ +document: + dsl: '1.0.1' + namespace: test + name: openapi-example + version: '0.1.0' +do: + - getUser: + call: openapi + with: + document: + endpoint: http://127.0.0.1:8887/schema.yaml + operationId: getUserById + parameters: + userId: 40099 + fields: + - id + - name + - email + include_deleted: false + format: full + lang: en + limit: 20 \ No newline at end of file diff --git a/impl/test/src/test/resources/workflows-samples/openapi/project-post-positive.yaml b/impl/test/src/test/resources/workflows-samples/openapi/project-post-positive.yaml new file mode 100644 index 00000000..8c15dcd5 --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/openapi/project-post-positive.yaml @@ -0,0 +1,24 @@ +document: + dsl: '1.0.1' + namespace: test + name: openapi-example + version: '0.1.0' +do: + - addProject: + call: openapi + with: + document: + endpoint: http://127.0.0.1:8887/schema.yaml + operationId: addProject + parameters: + name: "New CRM" + code: "crm-20251111" + description: "Internal CRM for sales department" + ownerId: 12345 + members: + - 12345 + - 67890 + validateOnly: false + notifyMembers: true + lang: en + Authorization: "Bearer eyJhbnNpc2l0b3IuYm9sdXMubWFnbnVz" \ No newline at end of file diff --git a/pom.xml b/pom.xml index 96faa82a..12d75994 100644 --- a/pom.xml +++ b/pom.xml @@ -97,6 +97,9 @@ 1.4.1-beta10 1.4.0 + + 2.1.33 + true