Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/*
* 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 com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;

public class JacksonOpenAPI {

enum SwaggerVersion {
SWAGGER_V2,
OPENAPI_V3
}

private final JsonNode root;
private final boolean isSwaggerV2;

public JacksonOpenAPI(JsonNode root) {
this.root = Objects.requireNonNull(root, "root cannot be null");
this.isSwaggerV2 = isSwaggerV2();
this.validatePaths();
this.moveRequestInBodyToRequestBody();
}

private void moveRequestInBodyToRequestBody() {
if (isSwaggerV2) {
Set<Map.Entry<String, JsonNode>> paths = root.get("paths").properties();
for (Map.Entry<String, JsonNode> path : paths) {
JsonNode pathNode = path.getValue();
Set<Map.Entry<String, JsonNode>> methods = pathNode.properties();
for (Map.Entry<String, JsonNode> method : methods) {
JsonNode operationNode = method.getValue();
if (operationNode.has("parameters")) {
for (int i = 0; i < operationNode.get("parameters").size(); i++) {
JsonNode parameterNode = operationNode.get("parameters").get(i);

if (parameterNode.has("in") && parameterNode.get("in").asText().equals("body")) {
// add to schema.$ref to requestBody

ObjectNode requestBodyNode = ((ObjectNode) operationNode).putObject("requestBody");
ObjectNode contentNode = requestBodyNode.putObject("content");
ObjectNode mediaTypeNode = contentNode.putObject("application/json");
ObjectNode schemaNode = mediaTypeNode.putObject("schema");
if (parameterNode.has("schema")) {
schemaNode.setAll((ObjectNode) parameterNode.get("schema"));
}
// remove from parameters
ArrayNode parametersArray = (ArrayNode) operationNode.get("parameters");

parametersArray.remove(i);
}
}
}
}
}
}
}

private void validatePaths() {
if (!root.has("paths")) {
throw new IllegalArgumentException("OpenAPI document must contain 'paths' field");
}
}

private boolean isSwaggerV2() {
JsonNode swaggerNode = root.get("swagger");
return swaggerNode != null && swaggerNode.asText().startsWith("2.0");
}

public PathItemInfo findOperationById(String operationId) {
JsonNode paths = root.get("paths");

Set<Map.Entry<String, JsonNode>> properties = paths.properties();

for (Map.Entry<String, JsonNode> path : properties) {
JsonNode pathNode = path.getValue();
Set<Map.Entry<String, JsonNode>> methods = pathNode.properties();
for (Map.Entry<String, JsonNode> method : methods) {
JsonNode operationNode = method.getValue().get("operationId");
if (operationNode != null && operationNode.asText().equals(operationId)) {
return new PathItemInfo(path.getKey(), path.getValue(), method.getKey());
}
}
}
throw new IllegalArgumentException("Operation with ID " + operationId + " not found");
}

public List<String> getServers() {

if (isSwaggerV2) {
if (root.has("host")) {
String host = root.get("host").asText();
String basePath = root.has("basePath") ? root.get("basePath").asText() : "";
String scheme = "http";
if (root.has("schemes")
&& root.get("schemes").isArray()
&& !root.get("schemes").isEmpty()) {
scheme = root.get("schemes").get(0).asText();
}
return List.of(scheme + "://" + host + basePath);
} else {
return List.of();
}
}

return root.has("servers") ? List.of(root.get("servers").findPath("url").asText()) : List.of();
}

public SwaggerVersion getSwaggerVersion() {
return isSwaggerV2 ? SwaggerVersion.SWAGGER_V2 : SwaggerVersion.OPENAPI_V3;
}

public JsonNode resolveSchema(String ref) {
if (!ref.startsWith("#/")) {
throw new IllegalArgumentException("Only local references are supported");
}
String[] parts = ref.substring(2).split("/");
JsonNode currentNode = root;
for (String part : parts) {
currentNode = currentNode.get(part);
if (currentNode == null) {
throw new IllegalArgumentException("Reference " + ref + " could not be resolved");
}
}
return currentNode;
}

public record PathItemInfo(String path, JsonNode operation, String method) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/*
* Copyright 2020-Present The Serverless Workflow Specification Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.serverlessworkflow.impl.executors.openapi;

import com.fasterxml.jackson.databind.JsonNode;
import io.serverlessworkflow.api.types.ExternalResource;
import io.serverlessworkflow.impl.TaskContext;
import io.serverlessworkflow.impl.WorkflowApplication;
import io.serverlessworkflow.impl.WorkflowContext;
import io.serverlessworkflow.impl.WorkflowModel;
import io.serverlessworkflow.impl.executors.CallableTask;
import io.serverlessworkflow.impl.executors.http.HttpExecutor;
import io.serverlessworkflow.impl.executors.http.HttpExecutorBuilder;
import io.serverlessworkflow.impl.resources.ResourceLoaderUtils;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture;

class JacksonOpenAPIExecutor implements CallableTask {

private final JacksonOpenAPIProcessor processor;
private final ExternalResource resource;
private final Map<String, Object> parameters;
private final HttpExecutorBuilder builder;

JacksonOpenAPIExecutor(
JacksonOpenAPIProcessor processor,
ExternalResource resource,
Map<String, Object> parameters,
HttpExecutorBuilder builder) {
this.processor = processor;
this.resource = resource;
this.parameters = parameters;
this.builder = builder;
}

@Override
public CompletableFuture<WorkflowModel> apply(
WorkflowContext workflowContext, TaskContext taskContext, WorkflowModel input) {

// In the same workflow, access to an already cached document
final JacksonOperationDefinition operationDefinition =
processor.parse(
workflowContext
.definition()
.resourceLoader()
.load(
resource,
ResourceLoaderUtils::readString,
workflowContext,
taskContext,
input));

fillHttpBuilder(workflowContext.definition().application(), operationDefinition);
// One executor per operation, even if the document is the same
// Me may refactor this even further to reuse the same executor (since the base URI is the same,
// but the path differs, although some use cases may require different client configurations for
// different paths...)
Collection<HttpExecutor> executors =
operationDefinition.getServers().stream().map(s -> builder.build(s)).toList();

Iterator<HttpExecutor> iter = executors.iterator();
if (!iter.hasNext()) {
throw new IllegalArgumentException(
"List of servers is empty for schema " + resource.getName());
}
CompletableFuture<WorkflowModel> future =
iter.next().apply(workflowContext, taskContext, input);
while (iter.hasNext()) {
future.exceptionallyCompose(i -> iter.next().apply(workflowContext, taskContext, input));
}
return future;
}

private void fillHttpBuilder(
WorkflowApplication application, JacksonOperationDefinition operation) {
Map<String, Object> headersMap = new HashMap<>();
Map<String, Object> queryMap = new HashMap<>();
Map<String, Object> pathParameters = new HashMap<>();
Set<String> missingParams = new HashSet<>();

Map<String, Object> bodyParameters = new HashMap<>(parameters);
for (JacksonParameterDefinition parameter : operation.getParameters()) {
switch (parameter.getIn()) {
case "header":
param(parameter, bodyParameters, headersMap, missingParams);
break;
case "path":
param(parameter, bodyParameters, pathParameters, missingParams);
break;
case "query":
param(parameter, bodyParameters, queryMap, missingParams);
break;
}
}

if (!missingParams.isEmpty()) {
throw new IllegalArgumentException(
"Missing required OpenAPI parameters for operation '"
+ (operation.getOperation().get("operationId") != null
? operation.getOperation().get("operationId").asText()
: "<unknown>" + "': ")
+ missingParams);
}
builder
.withMethod(operation.getMethod())
.withPath(new OperationPathResolver(operation.getPath(), application, pathParameters))
.withBody(bodyParameters)
.withQueryMap(queryMap)
.withHeaders(headersMap);
}

private void param(
JacksonParameterDefinition parameter,
Map<String, Object> origMap,
Map<String, Object> collectorMap,
Set<String> missingParams) {
String name = parameter.getName();
if (origMap.containsKey(name)) {
collectorMap.put(parameter.getName(), origMap.remove(name));
} else if (parameter.getRequired()) {

JsonNode schema = parameter.getSchema();
Object defaultValue = schema != null ? schema.get("default") : null;
if (defaultValue != null) {
collectorMap.put(name, defaultValue);
} else {
missingParams.add(name);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/*
* 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 com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;
import java.util.Objects;

/**
* Parses OpenAPI content (JSON or YAML) into a {@link JacksonOpenAPI} using Jackson.
*
* <p>This class detects JSON if the first non-whitespace character is '{'; otherwise it treats the
* content as YAML.
*/
public final class JacksonOpenAPIParser {

private static final ObjectMapper YAML_MAPPER = new YAMLMapper();
private static final ObjectMapper JSON_MAPPER = new JsonMapper();

/**
* Parse the provided OpenAPI content (JSON or YAML) and return a {@link JacksonOpenAPI}.
*
* @param content the OpenAPI document content (must not be null or blank)
* @return parsed {@link JacksonOpenAPI}
* @throws IllegalArgumentException if content is null/blank or cannot be parsed
*/
public JacksonOpenAPI parse(String content) {
Objects.requireNonNull(content, "content must not be null");
String trimmed = content.trim();
if (trimmed.isEmpty()) {
throw new IllegalArgumentException("content must not be blank");
}

ObjectMapper mapper = selectMapper(trimmed);
try {
JsonNode root = mapper.readTree(content);
return new JacksonOpenAPI(root);
} catch (Exception e) {
throw new IllegalArgumentException("Failed to parse content", e);
}
}

ObjectMapper selectMapper(String trimmedContent) {
char first = firstNonWhitespaceChar(trimmedContent);
if (first == '{') {
return JSON_MAPPER;
}
return YAML_MAPPER;
}

private static char firstNonWhitespaceChar(String s) {
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (!Character.isWhitespace(c)) {
return c;
}
}
return '\0';
}
}
Loading