From 92da15d97c517c035be49db13cc7a6b4404c8fa9 Mon Sep 17 00:00:00 2001 From: Francisco Javier Tirado Sarti Date: Tue, 16 Apr 2024 19:58:32 +0200 Subject: [PATCH] [Fix #3465] Support function arguments --- .../parser/handlers/MappingSetter.java | 26 +++++++ .../parser/handlers/MappingUtils.java | 74 ++++++++++++++++++ .../parser/handlers/NodeFactoryUtils.java | 7 -- .../workflow/utils/WorkItemBuilder.java | 75 +++++++------------ .../workflow/parser/types/DMNTypeHandler.java | 26 ++++++- .../serverless/workflow/dmn/SWFDMNTest.java | 24 ++++-- .../workflow/dmn/SWFDecisionEngine.java | 40 +++++++++- 7 files changed, 206 insertions(+), 66 deletions(-) create mode 100644 kogito-serverless-workflow/kogito-serverless-workflow-builder/src/main/java/org/kie/kogito/serverless/workflow/parser/handlers/MappingSetter.java create mode 100644 kogito-serverless-workflow/kogito-serverless-workflow-builder/src/main/java/org/kie/kogito/serverless/workflow/parser/handlers/MappingUtils.java diff --git a/kogito-serverless-workflow/kogito-serverless-workflow-builder/src/main/java/org/kie/kogito/serverless/workflow/parser/handlers/MappingSetter.java b/kogito-serverless-workflow/kogito-serverless-workflow-builder/src/main/java/org/kie/kogito/serverless/workflow/parser/handlers/MappingSetter.java new file mode 100644 index 00000000000..adfa6a4e627 --- /dev/null +++ b/kogito-serverless-workflow/kogito-serverless-workflow-builder/src/main/java/org/kie/kogito/serverless/workflow/parser/handlers/MappingSetter.java @@ -0,0 +1,26 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.kie.kogito.serverless.workflow.parser.handlers; + +public interface MappingSetter { + + void accept(Object value); + + void accept(String key, Object value); +} diff --git a/kogito-serverless-workflow/kogito-serverless-workflow-builder/src/main/java/org/kie/kogito/serverless/workflow/parser/handlers/MappingUtils.java b/kogito-serverless-workflow/kogito-serverless-workflow-builder/src/main/java/org/kie/kogito/serverless/workflow/parser/handlers/MappingUtils.java new file mode 100644 index 00000000000..d666cdf71c5 --- /dev/null +++ b/kogito-serverless-workflow/kogito-serverless-workflow-builder/src/main/java/org/kie/kogito/serverless/workflow/parser/handlers/MappingUtils.java @@ -0,0 +1,74 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.kie.kogito.serverless.workflow.parser.handlers; + +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Map.Entry; + +import org.jbpm.ruleflow.core.factory.MappableNodeFactory; +import org.kie.kogito.jackson.utils.JsonNodeVisitor; +import org.kie.kogito.jackson.utils.JsonObjectUtils; +import org.kie.kogito.serverless.workflow.SWFConstants; +import org.kie.kogito.serverless.workflow.utils.ExpressionHandlerUtils; + +import com.fasterxml.jackson.databind.JsonNode; + +import io.serverlessworkflow.api.Workflow; + +public class MappingUtils { + + public static > T addMapping(T nodeFactory, String inputVar, String outputVar) { + return (T) nodeFactory.inMapping(inputVar, SWFConstants.MODEL_WORKFLOW_VAR) + .outMapping(SWFConstants.RESULT, outputVar); + } + + public static final void processArgs(Workflow workflow, MappingSetter setter, + JsonNode functionArgs) { + if (functionArgs.isObject()) { + functionsToMap(workflow, functionArgs).forEach((key, value) -> setter.accept(key, value)); + } else { + Object object = functionReference(workflow, JsonObjectUtils.simpleToJavaValue(functionArgs)); + setter.accept(object); + } + } + + private static Map functionsToMap(Workflow workflow, JsonNode jsonNode) { + Map map = new LinkedHashMap<>(); + if (jsonNode != null) { + Iterator> iter = jsonNode.fields(); + while (iter.hasNext()) { + Entry entry = iter.next(); + map.put(entry.getKey(), functionReference(workflow, JsonObjectUtils.simpleToJavaValue(entry.getValue()))); + } + } + return map; + } + + private static Object functionReference(Workflow workflow, Object object) { + if (object instanceof JsonNode) { + return JsonNodeVisitor.transformTextNode((JsonNode) object, node -> JsonObjectUtils.fromValue(ExpressionHandlerUtils.replaceExpr(workflow, node.asText()))); + } else if (object instanceof CharSequence) { + return ExpressionHandlerUtils.replaceExpr(workflow, object.toString()); + } else { + return object; + } + } +} diff --git a/kogito-serverless-workflow/kogito-serverless-workflow-builder/src/main/java/org/kie/kogito/serverless/workflow/parser/handlers/NodeFactoryUtils.java b/kogito-serverless-workflow/kogito-serverless-workflow-builder/src/main/java/org/kie/kogito/serverless/workflow/parser/handlers/NodeFactoryUtils.java index 2d64a2d7c6b..18590dc51e6 100644 --- a/kogito-serverless-workflow/kogito-serverless-workflow-builder/src/main/java/org/kie/kogito/serverless/workflow/parser/handlers/NodeFactoryUtils.java +++ b/kogito-serverless-workflow/kogito-serverless-workflow-builder/src/main/java/org/kie/kogito/serverless/workflow/parser/handlers/NodeFactoryUtils.java @@ -27,7 +27,6 @@ import org.jbpm.ruleflow.core.RuleFlowNodeContainerFactory; import org.jbpm.ruleflow.core.factory.EventNodeFactory; import org.jbpm.ruleflow.core.factory.JoinFactory; -import org.jbpm.ruleflow.core.factory.MappableNodeFactory; import org.jbpm.ruleflow.core.factory.NodeFactory; import org.jbpm.ruleflow.core.factory.SplitFactory; import org.jbpm.ruleflow.core.factory.StartNodeFactory; @@ -39,7 +38,6 @@ import org.jbpm.workflow.core.node.Split; import org.kie.kogito.correlation.CompositeCorrelation; import org.kie.kogito.correlation.SimpleCorrelation; -import org.kie.kogito.serverless.workflow.SWFConstants; import org.kie.kogito.serverless.workflow.parser.ServerlessWorkflowParser; import io.serverlessworkflow.api.events.EventDefinition; @@ -136,11 +134,6 @@ private static CompositeCorrelation getCorrelationAttributes(EventDefinition eve .metaData("EventBased", "true"); } - public static > T addMapping(T nodeFactory, String inputVar, String outputVar) { - return (T) nodeFactory.inMapping(inputVar, SWFConstants.MODEL_WORKFLOW_VAR) - .outMapping(SWFConstants.RESULT, outputVar); - } - public static > SplitFactory exclusiveSplitNode(SplitFactory nodeFactory) { return nodeFactory.name("ExclusiveSplit_" + nodeFactory.getNode().getId().toExternalFormat()) .type(Split.TYPE_XOR); diff --git a/kogito-serverless-workflow/kogito-serverless-workflow-builder/src/main/java/org/kie/kogito/serverless/workflow/utils/WorkItemBuilder.java b/kogito-serverless-workflow/kogito-serverless-workflow-builder/src/main/java/org/kie/kogito/serverless/workflow/utils/WorkItemBuilder.java index f6ff2ae0cd8..0c5e2e0b68e 100644 --- a/kogito-serverless-workflow/kogito-serverless-workflow-builder/src/main/java/org/kie/kogito/serverless/workflow/utils/WorkItemBuilder.java +++ b/kogito-serverless-workflow/kogito-serverless-workflow-builder/src/main/java/org/kie/kogito/serverless/workflow/utils/WorkItemBuilder.java @@ -19,21 +19,17 @@ package org.kie.kogito.serverless.workflow.utils; import java.util.Collection; -import java.util.Iterator; -import java.util.LinkedHashMap; import java.util.Map; -import java.util.Map.Entry; import org.jbpm.process.core.datatype.DataType; import org.jbpm.process.core.datatype.DataTypeResolver; import org.jbpm.ruleflow.core.RuleFlowNodeContainerFactory; import org.jbpm.ruleflow.core.factory.WorkItemNodeFactory; -import org.kie.kogito.jackson.utils.JsonNodeVisitor; -import org.kie.kogito.jackson.utils.JsonObjectUtils; import org.kie.kogito.process.expr.ExpressionHandlerFactory; import org.kie.kogito.serverless.workflow.SWFConstants; import org.kie.kogito.serverless.workflow.parser.ParserContext; -import org.kie.kogito.serverless.workflow.parser.handlers.NodeFactoryUtils; +import org.kie.kogito.serverless.workflow.parser.handlers.MappingSetter; +import org.kie.kogito.serverless.workflow.parser.handlers.MappingUtils; import org.kie.kogito.serverless.workflow.suppliers.ExpressionParametersFactorySupplier; import org.kie.kogito.serverless.workflow.suppliers.ObjectResolverSupplier; @@ -71,57 +67,40 @@ protected WorkItemNodeFactory buildWorkItem(RuleFlowNodeContainerFactory workItemFactory, JsonNode functionArgs, String paramName) { - if (functionArgs.isObject()) { - functionsToMap(workflow, functionArgs).forEach((key, value) -> processArg(workflow, key, value, workItemFactory, paramName)); - } else { - Object object = functionReference(workflow, JsonObjectUtils.simpleToJavaValue(functionArgs)); - boolean isExpr = isExpression(workflow, object); - if (isExpr) { - workItemFactory.workParameterFactory(new ExpressionParametersFactorySupplier(workflow.getExpressionLang(), object, paramName)); - } else { - workItemFactory.workParameter(SWFConstants.CONTENT_DATA, object); + MappingUtils.processArgs(workflow, new MappingSetter() { + @Override + public void accept(String key, Object value) { + boolean isExpr = isExpression(workflow, value); + workItemFactory + .workParameter(key, + isExpr ? new ObjectResolverSupplier(workflow.getExpressionLang(), value, paramName) : value) + .workParameterDefinition(key, + getDataType(value, isExpr)); } - workItemFactory.workParameterDefinition(SWFConstants.CONTENT_DATA, getDataType(object, isExpr)); - } - } - private Map functionsToMap(Workflow workflow, JsonNode jsonNode) { - Map map = new LinkedHashMap<>(); - if (jsonNode != null) { - Iterator> iter = jsonNode.fields(); - while (iter.hasNext()) { - Entry entry = iter.next(); - map.put(entry.getKey(), functionReference(workflow, JsonObjectUtils.simpleToJavaValue(entry.getValue()))); + @Override + public void accept(Object value) { + boolean isExpr = isExpression(workflow, value); + if (isExpr) { + workItemFactory.workParameterFactory(new ExpressionParametersFactorySupplier(workflow.getExpressionLang(), value, paramName)); + } else { + workItemFactory.workParameter(SWFConstants.CONTENT_DATA, value); + } + workItemFactory.workParameterDefinition(SWFConstants.CONTENT_DATA, getDataType(value, isExpr)); } - } - return map; - } - - private Object functionReference(Workflow workflow, Object object) { - if (object instanceof JsonNode) { - return JsonNodeVisitor.transformTextNode((JsonNode) object, node -> JsonObjectUtils.fromValue(ExpressionHandlerUtils.replaceExpr(workflow, node.asText()))); - } else if (object instanceof CharSequence) { - return ExpressionHandlerUtils.replaceExpr(workflow, object.toString()); - } else { - return object; - } + }, functionArgs); } - private void processArg(Workflow workflow, String key, Object value, WorkItemNodeFactory workItemFactory, String paramName) { - boolean isExpr = isExpression(workflow, value); - workItemFactory - .workParameter(key, - isExpr ? new ObjectResolverSupplier(workflow.getExpressionLang(), value, paramName) : value) - .workParameterDefinition(key, - getDataType(value, isExpr)); + private static boolean isExpression(Workflow workflow, Object value) { + return value instanceof CharSequence && ExpressionHandlerFactory.get(workflow.getExpressionLang(), value.toString()).isValid() || value instanceof JsonNode; } - DataType getDataType(Object object, boolean isExpr) { + private static DataType getDataType(Object object, boolean isExpr) { if (object instanceof ObjectNode) { return DataTypeResolver.fromClass(Map.class); } else if (object instanceof ArrayNode) { @@ -130,8 +109,4 @@ DataType getDataType(Object object, boolean isExpr) { return DataTypeResolver.fromObject(object, isExpr); } } - - private boolean isExpression(Workflow workflow, Object value) { - return value instanceof CharSequence && ExpressionHandlerFactory.get(workflow.getExpressionLang(), value.toString()).isValid() || value instanceof JsonNode; - } } diff --git a/kogito-serverless-workflow/kogito-serverless-workflow-dmn-parser/src/main/java/org/kie/kogito/serverless/workflow/parser/types/DMNTypeHandler.java b/kogito-serverless-workflow/kogito-serverless-workflow-dmn-parser/src/main/java/org/kie/kogito/serverless/workflow/parser/types/DMNTypeHandler.java index 157bd0d7f2d..b2327f0dfc8 100644 --- a/kogito-serverless-workflow/kogito-serverless-workflow-dmn-parser/src/main/java/org/kie/kogito/serverless/workflow/parser/types/DMNTypeHandler.java +++ b/kogito-serverless-workflow/kogito-serverless-workflow-dmn-parser/src/main/java/org/kie/kogito/serverless/workflow/parser/types/DMNTypeHandler.java @@ -27,14 +27,20 @@ import org.jbpm.ruleflow.core.RuleFlowNodeContainerFactory; import org.jbpm.ruleflow.core.factory.NodeFactory; +import org.jbpm.ruleflow.core.factory.RuleSetNodeFactory; import org.kie.kogito.decision.DecisionModel; import org.kie.kogito.dmn.DMNKogito; import org.kie.kogito.dmn.DmnDecisionModel; +import org.kie.kogito.serverless.workflow.SWFConstants; +import org.kie.kogito.serverless.workflow.dmn.SWFDecisionEngine; import org.kie.kogito.serverless.workflow.io.URIContentLoaderFactory; import org.kie.kogito.serverless.workflow.parser.FunctionTypeHandler; import org.kie.kogito.serverless.workflow.parser.ParserContext; import org.kie.kogito.serverless.workflow.parser.VariableInfo; -import org.kie.kogito.serverless.workflow.parser.handlers.NodeFactoryUtils; +import org.kie.kogito.serverless.workflow.parser.handlers.MappingSetter; +import org.kie.kogito.serverless.workflow.parser.handlers.MappingUtils; + +import com.fasterxml.jackson.databind.JsonNode; import io.serverlessworkflow.api.Workflow; import io.serverlessworkflow.api.functions.FunctionDefinition; @@ -67,8 +73,24 @@ public boolean isCustom() { String namespace = Objects.requireNonNull(metadata.get(NAMESPACE), String.format(REQUIRED_MESSAGE, NAMESPACE)); String model = Objects.requireNonNull(metadata.get(MODEL), String.format(REQUIRED_MESSAGE, MODEL)); String file = Objects.requireNonNull(metadata.get(FILE), String.format(REQUIRED_MESSAGE, FILE)); - return NodeFactoryUtils.addMapping(embeddedSubProcess.ruleSetNode(context.newId()).decision(namespace, model, model, () -> loadDMNFromFile(namespace, model, file)), + RuleSetNodeFactory nodeFactory = MappingUtils.addMapping(embeddedSubProcess.ruleSetNode(context.newId()).decision(namespace, model, model, () -> loadDMNFromFile(namespace, model, file)), varInfo.getInputVar(), varInfo.getOutputVar()); + JsonNode functionArgs = functionRef.getArguments(); + if (functionArgs != null) { + nodeFactory.metaData(SWFDecisionEngine.EXPR_LANG, workflow.getExpressionLang()); + MappingUtils.processArgs(workflow, new MappingSetter() { + @Override + public void accept(String key, Object value) { + nodeFactory.parameter(key, value); + } + + @Override + public void accept(Object value) { + nodeFactory.parameter(SWFConstants.CONTENT_DATA, value); + } + }, functionArgs); + } + return nodeFactory; } private DecisionModel loadDMNFromFile(String namespace, String model, String file) { diff --git a/kogito-serverless-workflow/kogito-serverless-workflow-dmn-parser/src/test/java/org/kie/kogito/serverless/workflow/dmn/SWFDMNTest.java b/kogito-serverless-workflow/kogito-serverless-workflow-dmn-parser/src/test/java/org/kie/kogito/serverless/workflow/dmn/SWFDMNTest.java index 29cefeb5c00..deb04a4fb27 100644 --- a/kogito-serverless-workflow/kogito-serverless-workflow-dmn-parser/src/test/java/org/kie/kogito/serverless/workflow/dmn/SWFDMNTest.java +++ b/kogito-serverless-workflow/kogito-serverless-workflow-dmn-parser/src/test/java/org/kie/kogito/serverless/workflow/dmn/SWFDMNTest.java @@ -1,6 +1,7 @@ package org.kie.kogito.serverless.workflow.dmn; import java.io.IOException; +import java.util.Collections; import java.util.Date; import java.util.Map; @@ -22,12 +23,25 @@ public class SWFDMNTest { @Test void testDMNFile() throws IOException { + doIt(buildWorkflow(Collections.emptyMap())); + } + + @Test + void testDMNFileWithArgs() throws IOException { + doIt(buildWorkflow(Map.of("Driver", ".Driver", "Violation", ".Violation"))); + } + + private Workflow buildWorkflow(Map args) { + return workflow("PlayingWithDMN") + .start(operation().action(call(custom("DMNTest", "dmn").metadata(DMNTypeHandler.FILE, "Traffic Violation.dmn") + .metadata(DMNTypeHandler.MODEL, "Traffic Violation") + .metadata(DMNTypeHandler.NAMESPACE, "https://github.com/kiegroup/drools/kie-dmn/_A4BCA8B8-CF08-433F-93B2-A2598F19ECFF"), args)) + .outputFilter("{\"Should the driver be suspended?\"}")) + .end().build(); + } + + private void doIt(Workflow workflow) { try (StaticWorkflowApplication application = StaticWorkflowApplication.create()) { - Workflow workflow = workflow("PlayingWithDMN") - .start(operation().action(call(custom("DMNTest", "dmn").metadata(DMNTypeHandler.FILE, "Traffic Violation.dmn") - .metadata(DMNTypeHandler.MODEL, "Traffic Violation") - .metadata(DMNTypeHandler.NAMESPACE, "https://github.com/kiegroup/drools/kie-dmn/_A4BCA8B8-CF08-433F-93B2-A2598F19ECFF")))) - .end().build(); JsonNode response = application.execute(workflow, Map.of("Driver", Map.of("Name", "Pepe", "Age", 19, "Points", 0, "State", "Spain", "City", "Zaragoza"), "Violation", Map.of("Code", "12", "Date", new Date(System.currentTimeMillis()), "Type", "parking"))).getWorkflowdata(); assertThat(response.get("Should the driver be suspended?")).isEqualTo(new TextNode("No")); diff --git a/kogito-serverless-workflow/kogito-serverless-workflow-dmn/src/main/java/org/kie/kogito/serverless/workflow/dmn/SWFDecisionEngine.java b/kogito-serverless-workflow/kogito-serverless-workflow-dmn/src/main/java/org/kie/kogito/serverless/workflow/dmn/SWFDecisionEngine.java index 84419c9fba1..f746b8c5c11 100644 --- a/kogito-serverless-workflow/kogito-serverless-workflow-dmn/src/main/java/org/kie/kogito/serverless/workflow/dmn/SWFDecisionEngine.java +++ b/kogito-serverless-workflow/kogito-serverless-workflow-dmn/src/main/java/org/kie/kogito/serverless/workflow/dmn/SWFDecisionEngine.java @@ -1,10 +1,13 @@ package org.kie.kogito.serverless.workflow.dmn; +import java.util.HashMap; import java.util.Map; import java.util.Optional; import java.util.stream.Collectors; +import org.jbpm.util.ContextFactory; import org.jbpm.workflow.core.impl.NodeIoHelper; +import org.jbpm.workflow.core.node.RuleSetNode; import org.jbpm.workflow.instance.node.RuleSetNodeInstance; import org.jbpm.workflow.instance.rule.DecisionRuleTypeEngine; import org.kie.api.runtime.KieSession; @@ -15,16 +18,20 @@ import org.kie.kogito.decision.DecisionModel; import org.kie.kogito.dmn.DmnDecisionModel; import org.kie.kogito.dmn.rest.DMNJSONUtils; +import org.kie.kogito.internal.process.runtime.KogitoProcessContext; import org.kie.kogito.jackson.utils.JsonObjectUtils; +import org.kie.kogito.process.expr.Expression; +import org.kie.kogito.process.expr.ExpressionHandlerFactory; import org.kie.kogito.serverless.workflow.SWFConstants; public class SWFDecisionEngine implements DecisionRuleTypeEngine { + public static final String EXPR_LANG = "lang"; + @Override public void evaluate(RuleSetNodeInstance rsni, String inputNamespace, String inputModel, String decision) { String namespace = rsni.resolveExpression(inputNamespace); String model = rsni.resolveExpression(inputModel); - DecisionModel modelInstance = Optional.ofNullable(rsni.getRuleSetNode().getDecisionModel()) .orElse(() -> new DmnDecisionModel( @@ -34,7 +41,7 @@ public void evaluate(RuleSetNodeInstance rsni, String inputNamespace, String inp .get(); //Input Binding - DMNContext context = DMNJSONUtils.ctx(modelInstance, JsonObjectUtils.convertValue(getInputs(rsni).get(SWFConstants.MODEL_WORKFLOW_VAR), Map.class)); + DMNContext context = DMNJSONUtils.ctx(modelInstance, getInputParameters(rsni)); DMNResult dmnResult = modelInstance.evaluateAll(context); if (dmnResult.hasErrors()) { String errors = dmnResult.getMessages(DMNMessage.Severity.ERROR).stream() @@ -50,4 +57,33 @@ public void evaluate(RuleSetNodeInstance rsni, String inputNamespace, String inp rsni.triggerCompleted(); } + private Map getInputParameters(RuleSetNodeInstance rsni) { + RuleSetNode node = rsni.getRuleSetNode(); + Map inputParameters = node.getParameters(); + if (inputParameters.isEmpty()) { + inputParameters = JsonObjectUtils.convertValue(getInputs(rsni).get(SWFConstants.MODEL_WORKFLOW_VAR), Map.class); + } else { + KogitoProcessContext context = ContextFactory.fromNode(rsni); + String exprLang = (String) node.getMetaData().get(EXPR_LANG); + inputParameters = getInputParameters(context, exprLang, new HashMap<>(inputParameters)); + } + return inputParameters; + + } + + private Map getInputParameters(KogitoProcessContext context, String exprLang, Map inputParameters) { + for (Map.Entry entry : inputParameters.entrySet()) { + Object value = entry.getValue(); + if (value instanceof Map) { + entry.setValue(getInputParameters(context, exprLang, (Map) value)); + } else if (value instanceof CharSequence) { + Expression expr = ExpressionHandlerFactory.get(exprLang, value.toString()); + if (expr.isValid()) { + entry.setValue(expr.eval(JsonObjectUtils.fromValue(context.getVariable(SWFConstants.DEFAULT_WORKFLOW_VAR)), Map.class, context)); + } + } + } + return inputParameters; + } + }