Skip to content

Commit 3ae028a

Browse files
committed
Proof of concept using only Jackson
Signed-off-by: Matheus Cruz <matheuscruz.dev@gmail.com>
1 parent 5e0916c commit 3ae028a

File tree

7 files changed

+836
-0
lines changed

7 files changed

+836
-0
lines changed
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
/*
2+
* Copyright 2020-Present The Serverless Workflow Specification Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.serverlessworkflow.impl.executors.openapi;
17+
18+
import com.fasterxml.jackson.databind.JsonNode;
19+
import com.fasterxml.jackson.databind.node.ArrayNode;
20+
import com.fasterxml.jackson.databind.node.ObjectNode;
21+
import java.util.List;
22+
import java.util.Map;
23+
import java.util.Objects;
24+
import java.util.Set;
25+
26+
public class JacksonOpenAPI {
27+
28+
enum SwaggerVersion {
29+
SWAGGER_V2,
30+
OPENAPI_V3
31+
}
32+
33+
private final JsonNode root;
34+
private final boolean isSwaggerV2;
35+
36+
public JacksonOpenAPI(JsonNode root) {
37+
this.root = Objects.requireNonNull(root, "root cannot be null");
38+
this.isSwaggerV2 = isSwaggerV2();
39+
this.validatePaths();
40+
this.moveRequestInBodyToRequestBody();
41+
}
42+
43+
private void moveRequestInBodyToRequestBody() {
44+
if (isSwaggerV2) {
45+
Set<Map.Entry<String, JsonNode>> paths = root.get("paths").properties();
46+
for (Map.Entry<String, JsonNode> path : paths) {
47+
JsonNode pathNode = path.getValue();
48+
Set<Map.Entry<String, JsonNode>> methods = pathNode.properties();
49+
for (Map.Entry<String, JsonNode> method : methods) {
50+
JsonNode operationNode = method.getValue();
51+
if (operationNode.has("parameters")) {
52+
for (int i = 0; i < operationNode.get("parameters").size(); i++) {
53+
JsonNode parameterNode = operationNode.get("parameters").get(i);
54+
55+
if (parameterNode.has("in") && parameterNode.get("in").asText().equals("body")) {
56+
// add to schema.$ref to requestBody
57+
58+
ObjectNode requestBodyNode = ((ObjectNode) operationNode).putObject("requestBody");
59+
ObjectNode contentNode = requestBodyNode.putObject("content");
60+
ObjectNode mediaTypeNode = contentNode.putObject("application/json");
61+
ObjectNode schemaNode = mediaTypeNode.putObject("schema");
62+
if (parameterNode.has("schema")) {
63+
schemaNode.setAll((ObjectNode) parameterNode.get("schema"));
64+
}
65+
// remove from parameters
66+
ArrayNode parametersArray = (ArrayNode) operationNode.get("parameters");
67+
68+
parametersArray.remove(i);
69+
}
70+
}
71+
}
72+
}
73+
}
74+
}
75+
}
76+
77+
private void validatePaths() {
78+
if (!root.has("paths")) {
79+
throw new IllegalArgumentException("OpenAPI document must contain 'paths' field");
80+
}
81+
}
82+
83+
private boolean isSwaggerV2() {
84+
JsonNode swaggerNode = root.get("swagger");
85+
return swaggerNode != null && swaggerNode.asText().startsWith("2.0");
86+
}
87+
88+
public PathItemInfo findOperationById(String operationId) {
89+
JsonNode paths = root.get("paths");
90+
91+
Set<Map.Entry<String, JsonNode>> properties = paths.properties();
92+
93+
for (Map.Entry<String, JsonNode> path : properties) {
94+
JsonNode pathNode = path.getValue();
95+
Set<Map.Entry<String, JsonNode>> methods = pathNode.properties();
96+
for (Map.Entry<String, JsonNode> method : methods) {
97+
JsonNode operationNode = method.getValue().get("operationId");
98+
if (operationNode != null && operationNode.asText().equals(operationId)) {
99+
return new PathItemInfo(path.getKey(), path.getValue(), method.getKey());
100+
}
101+
}
102+
}
103+
throw new IllegalArgumentException("Operation with ID " + operationId + " not found");
104+
}
105+
106+
public List<String> getServers() {
107+
108+
if (isSwaggerV2) {
109+
if (root.has("host")) {
110+
String host = root.get("host").asText();
111+
String basePath = root.has("basePath") ? root.get("basePath").asText() : "";
112+
String scheme = "http";
113+
if (root.has("schemes")
114+
&& root.get("schemes").isArray()
115+
&& !root.get("schemes").isEmpty()) {
116+
scheme = root.get("schemes").get(0).asText();
117+
}
118+
return List.of(scheme + "://" + host + basePath);
119+
} else {
120+
return List.of();
121+
}
122+
}
123+
124+
return root.has("servers") ? List.of(root.get("servers").findPath("url").asText()) : List.of();
125+
}
126+
127+
public SwaggerVersion getSwaggerVersion() {
128+
return isSwaggerV2 ? SwaggerVersion.SWAGGER_V2 : SwaggerVersion.OPENAPI_V3;
129+
}
130+
131+
public JsonNode resolveSchema(String ref) {
132+
if (!ref.startsWith("#/")) {
133+
throw new IllegalArgumentException("Only local references are supported");
134+
}
135+
String[] parts = ref.substring(2).split("/");
136+
JsonNode currentNode = root;
137+
for (String part : parts) {
138+
currentNode = currentNode.get(part);
139+
if (currentNode == null) {
140+
throw new IllegalArgumentException("Reference " + ref + " could not be resolved");
141+
}
142+
}
143+
return currentNode;
144+
}
145+
146+
public record PathItemInfo(String path, JsonNode operation, String method) {}
147+
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
/*
2+
* Copyright 2020-Present The Serverless Workflow Specification Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.serverlessworkflow.impl.executors.openapi;
17+
18+
import io.serverlessworkflow.api.types.ExternalResource;
19+
import io.serverlessworkflow.impl.TaskContext;
20+
import io.serverlessworkflow.impl.WorkflowApplication;
21+
import io.serverlessworkflow.impl.WorkflowContext;
22+
import io.serverlessworkflow.impl.WorkflowModel;
23+
import io.serverlessworkflow.impl.executors.CallableTask;
24+
import io.serverlessworkflow.impl.executors.http.HttpExecutor;
25+
import io.serverlessworkflow.impl.executors.http.HttpExecutorBuilder;
26+
import io.serverlessworkflow.impl.resources.ResourceLoaderUtils;
27+
import io.swagger.v3.oas.models.media.Schema;
28+
import java.util.Collection;
29+
import java.util.HashMap;
30+
import java.util.HashSet;
31+
import java.util.Iterator;
32+
import java.util.Map;
33+
import java.util.Set;
34+
import java.util.concurrent.CompletableFuture;
35+
36+
class JacksonOpenAPIExecutor implements CallableTask {
37+
38+
private final OpenAPIProcessor processor;
39+
private final ExternalResource resource;
40+
private final Map<String, Object> parameters;
41+
private final HttpExecutorBuilder builder;
42+
43+
JacksonOpenAPIExecutor(
44+
OpenAPIProcessor processor,
45+
ExternalResource resource,
46+
Map<String, Object> parameters,
47+
HttpExecutorBuilder builder) {
48+
this.processor = processor;
49+
this.resource = resource;
50+
this.parameters = parameters;
51+
this.builder = builder;
52+
}
53+
54+
@Override
55+
public CompletableFuture<WorkflowModel> apply(
56+
WorkflowContext workflowContext, TaskContext taskContext, WorkflowModel input) {
57+
58+
// In the same workflow, access to an already cached document
59+
final OperationDefinition operationDefinition =
60+
processor.parse(
61+
workflowContext
62+
.definition()
63+
.resourceLoader()
64+
.load(
65+
resource,
66+
ResourceLoaderUtils::readString,
67+
workflowContext,
68+
taskContext,
69+
input));
70+
71+
fillHttpBuilder(workflowContext.definition().application(), operationDefinition);
72+
// One executor per operation, even if the document is the same
73+
// Me may refactor this even further to reuse the same executor (since the base URI is the same,
74+
// but the path differs, although some use cases may require different client configurations for
75+
// different paths...)
76+
Collection<HttpExecutor> executors =
77+
operationDefinition.getServers().stream().map(s -> builder.build(s)).toList();
78+
79+
Iterator<HttpExecutor> iter = executors.iterator();
80+
if (!iter.hasNext()) {
81+
throw new IllegalArgumentException(
82+
"List of servers is empty for schema " + resource.getName());
83+
}
84+
CompletableFuture<WorkflowModel> future =
85+
iter.next().apply(workflowContext, taskContext, input);
86+
while (iter.hasNext()) {
87+
future.exceptionallyCompose(i -> iter.next().apply(workflowContext, taskContext, input));
88+
}
89+
return future;
90+
}
91+
92+
private void fillHttpBuilder(WorkflowApplication application, OperationDefinition operation) {
93+
Map<String, Object> headersMap = new HashMap<>();
94+
Map<String, Object> queryMap = new HashMap<>();
95+
Map<String, Object> pathParameters = new HashMap<>();
96+
Set<String> missingParams = new HashSet<>();
97+
98+
Map<String, Object> bodyParameters = new HashMap<>(parameters);
99+
for (ParameterDefinition parameter : operation.getParameters()) {
100+
switch (parameter.getIn()) {
101+
case "header":
102+
param(parameter, bodyParameters, headersMap, missingParams);
103+
break;
104+
case "path":
105+
param(parameter, bodyParameters, pathParameters, missingParams);
106+
break;
107+
case "query":
108+
param(parameter, bodyParameters, queryMap, missingParams);
109+
break;
110+
}
111+
}
112+
113+
if (!missingParams.isEmpty()) {
114+
throw new IllegalArgumentException(
115+
"Missing required OpenAPI parameters for operation '"
116+
+ (operation.getOperation().getOperationId() != null
117+
? operation.getOperation().getOperationId()
118+
: "<unknown>" + "': ")
119+
+ missingParams);
120+
}
121+
builder
122+
.withMethod(operation.getMethod())
123+
.withPath(new OperationPathResolver(operation.getPath(), application, pathParameters))
124+
.withBody(bodyParameters)
125+
.withQueryMap(queryMap)
126+
.withHeaders(headersMap);
127+
}
128+
129+
private void param(
130+
ParameterDefinition parameter,
131+
Map<String, Object> origMap,
132+
Map<String, Object> collectorMap,
133+
Set<String> missingParams) {
134+
String name = parameter.getName();
135+
if (origMap.containsKey(name)) {
136+
collectorMap.put(parameter.getName(), origMap.remove(name));
137+
} else if (parameter.getRequired()) {
138+
Schema<?> schema = parameter.getSchema();
139+
Object defaultValue = schema != null ? schema.getDefault() : null;
140+
if (defaultValue != null) {
141+
collectorMap.put(name, defaultValue);
142+
} else {
143+
missingParams.add(name);
144+
}
145+
}
146+
}
147+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/*
2+
* Copyright 2020-Present The Serverless Workflow Specification Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.serverlessworkflow.impl.executors.openapi;
17+
18+
import com.fasterxml.jackson.databind.JsonNode;
19+
import com.fasterxml.jackson.databind.ObjectMapper;
20+
import com.fasterxml.jackson.databind.json.JsonMapper;
21+
import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;
22+
import java.util.Objects;
23+
24+
/**
25+
* Parses OpenAPI content (JSON or YAML) into a {@link JacksonOpenAPI} using Jackson.
26+
*
27+
* <p>This class detects JSON if the first non-whitespace character is '{'; otherwise it treats the
28+
* content as YAML.
29+
*/
30+
public final class JacksonOpenAPIParser {
31+
32+
private static final ObjectMapper YAML_MAPPER = new YAMLMapper();
33+
private static final ObjectMapper JSON_MAPPER = new JsonMapper();
34+
35+
/**
36+
* Parse the provided OpenAPI content (JSON or YAML) and return a {@link JacksonOpenAPI}.
37+
*
38+
* @param content the OpenAPI document content (must not be null or blank)
39+
* @return parsed {@link JacksonOpenAPI}
40+
* @throws IllegalArgumentException if content is null/blank or cannot be parsed
41+
*/
42+
public JacksonOpenAPI parse(String content) {
43+
Objects.requireNonNull(content, "content must not be null");
44+
String trimmed = content.trim();
45+
if (trimmed.isEmpty()) {
46+
throw new IllegalArgumentException("content must not be blank");
47+
}
48+
49+
ObjectMapper mapper = selectMapper(trimmed);
50+
try {
51+
JsonNode root = mapper.readTree(content);
52+
return new JacksonOpenAPI(root);
53+
} catch (Exception e) {
54+
throw new IllegalArgumentException("Failed to parse content", e);
55+
}
56+
}
57+
58+
ObjectMapper selectMapper(String trimmedContent) {
59+
char first = firstNonWhitespaceChar(trimmedContent);
60+
if (first == '{') {
61+
return JSON_MAPPER;
62+
}
63+
return YAML_MAPPER;
64+
}
65+
66+
private static char firstNonWhitespaceChar(String s) {
67+
for (int i = 0; i < s.length(); i++) {
68+
char c = s.charAt(i);
69+
if (!Character.isWhitespace(c)) {
70+
return c;
71+
}
72+
}
73+
return '\0';
74+
}
75+
}

0 commit comments

Comments
 (0)