JsonNodeUtils.java
package com.bonitasoft.processbuilder.extension;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Objects;
import java.util.function.BiFunction;
import java.util.stream.StreamSupport;
/**
* Utility class for working with Jackson {@link JsonNode} objects and evaluating conditions.
* <p>
* Provides methods for:
* </p>
* <ul>
* <li>Converting {@link JsonNode} instances to their corresponding Java types</li>
* <li>Evaluating comparison conditions between values</li>
* <li>Safely comparing {@link Comparable} values of potentially different types</li>
* <li>Safely navigating JSON structures using a dot-separated path.</li>
* </ul>
* <p>
* This class is designed to be non-instantiable and should only be accessed via static methods.
* </p>
*
* @author Bonitasoft
* @since 1.0
*/
public final class JsonNodeUtils {
private static final Logger LOGGER = LoggerFactory.getLogger(JsonNodeUtils.class);
/**
* Operator constant for equals comparison.
*/
public static final String OP_EQUALS = "equals";
/**
* Operator constant for equals comparison (symbol form).
*/
public static final String OP_EQUALS_SYMBOL = "==";
/**
* Operator constant for not equals comparison.
*/
public static final String OP_NOT_EQUALS = "not_equals";
/**
* Operator constant for not equals comparison (symbol form).
*/
public static final String OP_NOT_EQUALS_SYMBOL = "!=";
/**
* Operator constant for contains comparison.
*/
public static final String OP_CONTAINS = "contains";
/**
* Operator constant for not contains comparison.
*/
public static final String OP_NOT_CONTAINS = "not_contains";
/**
* Operator constant for greater than comparison.
*/
public static final String OP_GREATER_THAN = "greater_than";
/**
* Operator constant for greater than comparison (symbol form).
*/
public static final String OP_GREATER_THAN_SYMBOL = ">";
/**
* Operator constant for less than comparison.
*/
public static final String OP_LESS_THAN = "less_than";
/**
* Operator constant for less than comparison (symbol form).
*/
public static final String OP_LESS_THAN_SYMBOL = "<";
/**
* Operator constant for greater or equal comparison.
*/
public static final String OP_GREATER_OR_EQUAL = "greater_or_equal";
/**
* Operator constant for greater or equal comparison (symbol form).
*/
public static final String OP_GREATER_OR_EQUAL_SYMBOL = ">=";
/**
* Operator constant for less or equal comparison.
*/
public static final String OP_LESS_OR_EQUAL = "less_or_equal";
/**
* Operator constant for less or equal comparison (symbol form).
*/
public static final String OP_LESS_OR_EQUAL_SYMBOL = "<=";
/**
* Operator constant for is empty check.
* <p>
* Evaluates to {@code true} if the value is null, an empty string,
* or a string containing only whitespace characters.
* </p>
*/
public static final String OP_IS_EMPTY = "is_empty";
/**
* Operator constant for is not empty check.
* <p>
* Evaluates to {@code true} if the value is not null, not an empty string,
* and not a string containing only whitespace characters.
* </p>
*/
public static final String OP_IS_NOT_EMPTY = "is_not_empty";
/**
* Private constructor to prevent instantiation of this utility class.
*
* @throws UnsupportedOperationException always, to enforce the utility pattern
*/
private JsonNodeUtils() {
throw new UnsupportedOperationException(
"This is a " + this.getClass().getSimpleName() + " class and cannot be instantiated."
);
}
/**
* Converts a {@link JsonNode} to its corresponding Java object type.
* <p>
* The conversion follows these rules:
* </p>
* <ul>
* <li>{@code null} or {@code NullNode} returns {@code null}</li>
* <li>Text nodes return {@link String}</li>
* <li>Boolean nodes return {@link Boolean}</li>
* <li>Integer nodes return {@link Integer}</li>
* <li>Long nodes return {@link Long}</li>
* <li>Double or Float nodes return {@link Double}</li>
* <li>Array or Object nodes return their JSON string representation</li>
* <li>Any other type returns the text representation</li>
* </ul>
*
* @param node the JsonNode to convert (may be null)
* @return the converted Java object, or null if the node is null or represents a JSON null
*/
public static Object convertJsonNodeToObject(JsonNode node) {
if (node == null || node.isNull()) {
return null;
}
if (node.isTextual()) {
return node.asText();
}
if (node.isBoolean()) {
return node.asBoolean();
}
if (node.isInt()) {
return node.asInt();
}
if (node.isLong()) {
return node.asLong();
}
if (node.isDouble() || node.isFloat()) {
return node.asDouble();
}
if (node.isArray() || node.isObject()) {
// For complex types, return the JSON string representation
return node.toString();
}
// Default fallback for any other node type
return node.asText();
}
/**
* Safely retrieves the value of a field from a JSON structure using a dot-separated path.
* <p>
* This method prevents {@code NullPointerException} by safely navigating through nested objects.
* </p>
*
* @param rootNode the starting {@link JsonNode} (e.g., the root of the JSON structure).
* @param path the dot-separated path to the desired field (e.g., "subject", "recipients.type").
* @return the {@link JsonNode} representing the value at the specified path, or {@code null} if the path is invalid,
* the field does not exist, or the value is JSON null.
*/
public static JsonNode getValueByPath(JsonNode rootNode, String path) {
if (rootNode == null || path == null || path.isBlank()) {
return null;
}
String[] pathSegments = path.split("\\.");
JsonNode currentNode = rootNode;
for (String segment : pathSegments) {
if (currentNode == null || !currentNode.isObject()) {
// If the current node is null or not an object, the path is invalid.
return null;
}
// .get(segment) safely returns null if the field does not exist
currentNode = currentNode.get(segment.trim());
}
// Return the final node. It might be null (if path failed) or NullNode (if value was explicit null).
return (currentNode == null || currentNode.isNull()) ? null : currentNode;
}
/**
* Safely retrieves the value of a field from a JSON string using a dot-separated path.
* <p>
* This is a convenience method that first converts the JSON string to a {@link JsonNode}
* and then navigates to the specified path.
* </p>
*
* @param jsonString the JSON string to parse (e.g., "{\"name\": \"John\", \"address\": {\"city\": \"Madrid\"}}").
* @param path the dot-separated path to the desired field (e.g., "address.city").
* @return the {@link JsonNode} representing the value at the specified path, or {@code null} if the JSON
* is invalid, the path is invalid, the field does not exist, or the value is JSON null.
*/
public static JsonNode getValueByPath(String jsonString, String path) {
JsonNode rootNode = convertStringToJsonNode(jsonString);
if (rootNode == null) {
return null;
}
return getValueByPath(rootNode, path);
}
/**
* Retrieves the text value at the specified path from a {@link JsonNode}.
* <p>
* This is a convenience method that navigates to the specified path and returns the value
* as a plain {@link String} (without JSON quotes). Unlike {@link #getValueByPath(JsonNode, String)}
* which returns a {@link JsonNode}, this method directly returns the text representation.
* </p>
*
* <p><b>Usage Example:</b></p>
* <pre>{@code
* JsonNode root = objectMapper.readTree("{\"user\": {\"name\": \"John\"}}");
* String name = JsonNodeUtils.getTextValueByPath(root, "user.name");
* // Returns: "John" (without quotes)
* }</pre>
*
* @param rootNode the starting {@link JsonNode} (e.g., the root of the JSON structure).
* @param path the dot-separated path to the desired field (e.g., "user.name", "address.city").
* @return the text value at the specified path, or {@code null} if the path is invalid,
* the field does not exist, or the value is JSON null.
*/
public static String getTextValueByPath(JsonNode rootNode, String path) {
JsonNode node = getValueByPath(rootNode, path);
return (node != null) ? node.asText() : null;
}
/**
* Retrieves the text value at the specified path from a JSON string.
* <p>
* This is a convenience method that first parses the JSON string, navigates to the specified path,
* and returns the value as a plain {@link String} (without JSON quotes).
* </p>
*
* <p><b>Usage Example:</b></p>
* <pre>{@code
* String json = "{\"address\": {\"city\": \"Madrid\", \"zip\": \"28001\"}}";
* String city = JsonNodeUtils.getTextValueByPath(json, "address.city");
* // Returns: "Madrid" (without quotes)
* }</pre>
*
* @param jsonString the JSON string to parse (e.g., "{\"name\": \"John\"}").
* @param path the dot-separated path to the desired field (e.g., "address.city").
* @return the text value at the specified path, or {@code null} if the JSON is invalid,
* the path is invalid, the field does not exist, or the value is JSON null.
*/
public static String getTextValueByPath(String jsonString, String path) {
JsonNode node = getValueByPath(jsonString, path);
return (node != null) ? node.asText() : null;
}
/**
* Converts a JSON string to a {@link JsonNode}.
* <p>
* This method safely parses a JSON string and returns the corresponding {@link JsonNode}.
* If the input is null, empty, or not valid JSON, it returns {@code null} and logs the error.
* </p>
*
* @param jsonString the JSON string to parse (may be null or empty).
* @return the parsed {@link JsonNode}, or {@code null} if the input is null, empty, or invalid JSON.
*/
public static JsonNode convertStringToJsonNode(String jsonString) {
if (jsonString == null || jsonString.isBlank()) {
LOGGER.debug("JSON string is null or blank. Returning null.");
return null;
}
ObjectMapper objectMapper = new ObjectMapper();
try {
JsonNode jsonNode = objectMapper.readTree(jsonString);
LOGGER.debug("Successfully parsed JSON string. Node type: {}", jsonNode.getNodeType());
return jsonNode;
} catch (JsonProcessingException e) {
LOGGER.error("Error parsing JSON string: {}", e.getMessage());
return null;
}
}
/**
* Evaluates a condition comparing two values using the specified operator.
* <p>
* Supported operators (case-insensitive):
* </p>
* <ul>
* <li>{@code equals} or {@code ==}: equality comparison</li>
* <li>{@code notequals} or {@code !=}: inequality comparison</li>
* <li>{@code contains}: string containment check</li>
* <li>{@code greaterthan} or {@code >}: greater than comparison</li>
* <li>{@code lessthan} or {@code <}: less than comparison</li>
* <li>{@code greaterorequal} or {@code >=}: greater than or equal comparison</li>
* <li>{@code lessorequal} or {@code <=}: less than or equal comparison</li>
* </ul>
*
* @param currentValue the current value to compare (may be null)
* @param operator the comparison operator (case-insensitive)
* @param expectedValue the expected value to compare against (may be null)
* @return {@code true} if the condition is satisfied, {@code false} otherwise
*/
public static boolean evaluateCondition(Object currentValue, String operator,
Object expectedValue) {
if (operator == null) {
LOGGER.warn("Operator is null. Defaulting to false.");
return false;
}
String op = operator.toLowerCase();
return switch (op) {
case OP_EQUALS, OP_EQUALS_SYMBOL -> evaluateEquals(currentValue, expectedValue);
case OP_NOT_EQUALS, OP_NOT_EQUALS_SYMBOL -> evaluateNotEquals(currentValue, expectedValue);
case OP_CONTAINS -> evaluateContains(currentValue, expectedValue);
case OP_NOT_CONTAINS -> !evaluateContains(currentValue, expectedValue);
case OP_GREATER_THAN, OP_GREATER_THAN_SYMBOL ->
evaluateComparison(currentValue, expectedValue, 1);
case OP_LESS_THAN, OP_LESS_THAN_SYMBOL ->
evaluateComparison(currentValue, expectedValue, -1);
case OP_GREATER_OR_EQUAL, OP_GREATER_OR_EQUAL_SYMBOL ->
evaluateComparisonOrEqual(currentValue, expectedValue, 1);
case OP_LESS_OR_EQUAL, OP_LESS_OR_EQUAL_SYMBOL ->
evaluateComparisonOrEqual(currentValue, expectedValue, -1);
case OP_IS_EMPTY -> evaluateIsEmpty(currentValue);
case OP_IS_NOT_EMPTY -> evaluateIsNotEmpty(currentValue);
default -> {
LOGGER.warn("Unknown operator: {}. Defaulting to false.", operator);
yield false;
}
};
}
/**
* Compares two {@link Comparable} values in a type-safe manner.
* <p>
* Comparison rules:
* </p>
* <ul>
* <li>If both values are {@link Number}, they are converted to {@link Double} for consistent comparison</li>
* <li>If both values are of the same type, they are compared directly</li>
* <li>If types differ (and are not both numbers), they are compared as strings</li>
* </ul>
*
* @param actual the actual value to compare (must not be null)
* @param expected the expected value to compare against (must not be null)
* @return a negative integer, zero, or a positive integer as the actual value
* is less than, equal to, or greater than the expected value
* @throws NullPointerException if actual or expected is null
*/
@SuppressWarnings("unchecked")
public static int compareValues(Comparable<?> actual, Comparable<?> expected) {
Objects.requireNonNull(actual, "Actual value cannot be null");
Objects.requireNonNull(expected, "Expected value cannot be null");
// If both are numbers, convert to Double for consistent comparison
if (actual instanceof Number actualNum && expected instanceof Number expectedNum) {
Double actualDouble = actualNum.doubleValue();
Double expectedDouble = expectedNum.doubleValue();
return actualDouble.compareTo(expectedDouble);
}
// If they are the same type, compare directly
if (actual.getClass() == expected.getClass()) {
return ((Comparable<Object>) actual).compareTo(expected);
}
// Different types: compare as strings
return actual.toString().compareTo(expected.toString());
}
/**
* Evaluates all conditions in a JSON array, returning true only if ALL conditions are met.
* <p>
* This method is designed for evaluating process conditions where each condition references
* a step and field, compares against an expected value using an operator. The actual data
* retrieval is delegated to the caller via the {@code dataValueResolver} function.
* </p>
* <p>
* Each condition in the array must have the following structure:
* </p>
* <pre>{@code
* {
* "stepRef": "step_identifier",
* "variableName": "field_name",
* "variableOperator": "equals",
* "variableValue": "expected_value"
* }
* }</pre>
* <p>
* The method short-circuits on the first failing condition (uses {@code allMatch}).
* </p>
*
* @param conditionsNode the JSON array containing condition objects (may be null or empty)
* @param dataValueResolver a function that takes (fieldRef, stepRef) and returns the current
* data value as a String, or null if not found
* @return {@code true} if all conditions are met or if conditionsNode is null/empty,
* {@code false} if any condition fails or has invalid structure
*/
public static boolean evaluateAllConditions(
JsonNode conditionsNode,
BiFunction<String, String, String> dataValueResolver) {
// Handle null or empty conditions array - considered as "no conditions to fail"
if (conditionsNode == null || !conditionsNode.isArray() || conditionsNode.isEmpty()) {
LOGGER.debug("No conditions to evaluate or conditionsNode is not an array. Returning true.");
return true;
}
// Validate dataValueResolver
if (dataValueResolver == null) {
LOGGER.error("dataValueResolver function is null. Cannot evaluate conditions.");
return false;
}
// ALL conditions must be true (allMatch short-circuits on first false)
return StreamSupport.stream(conditionsNode.spliterator(), false)
.allMatch(condition -> evaluateSingleCondition(condition, dataValueResolver));
}
/**
* Evaluates a single condition from a JSON object.
*
* @param condition the condition JSON object
* @param dataValueResolver the function to resolve data values
* @return true if the condition is met, false otherwise
*/
private static boolean evaluateSingleCondition(
JsonNode condition,
BiFunction<String, String, String> dataValueResolver) {
// Extract condition fields
String stepRef = getTextOrNull(condition, "stepRef");
String fieldRef = getTextOrNull(condition, "fieldRef");
String operator = getTextOrNull(condition, "operator");
JsonNode expectedValueNode = condition.get("value");
LOGGER.debug("Evaluating condition: stepRef='{}', fieldRef='{}', operator='{}'",
stepRef, fieldRef, operator);
// Validate operator
if (operator == null || operator.isBlank()) {
LOGGER.error("Invalid operator for field '{}'. Operator is null or blank.", fieldRef);
return false;
}
// Validate required fields
if (fieldRef == null || fieldRef.isBlank() || stepRef == null || stepRef.isBlank()) {
LOGGER.error("Missing fieldRef or stepRef for condition. fieldRef='{}', stepRef='{}'",
fieldRef, stepRef);
return false;
}
// Retrieve current value using the resolver function
String currentValue;
try {
currentValue = dataValueResolver.apply(fieldRef, stepRef);
} catch (Exception e) {
LOGGER.error("Error retrieving data for field '{}' in step '{}': {}",
fieldRef, stepRef, e.getMessage());
return false;
}
// Check if data was found
if (currentValue == null) {
LOGGER.warn("No data found for field '{}' in step '{}'. Condition fails.",
fieldRef, stepRef);
return false;
}
// Convert expected value and evaluate
Object expectedValue = convertJsonNodeToObject(expectedValueNode);
boolean conditionMet = evaluateCondition(currentValue, operator, expectedValue);
LOGGER.info("Condition '{}': current='{}' {} expected='{}' -> {}",
fieldRef, currentValue, operator, expectedValue,
conditionMet ? "PASSED" : "FAILED");
return conditionMet;
}
/**
* Safely extracts a text value from a JSON node path.
*
* @param node the parent node
* @param fieldName the field name to extract
* @return the text value, or null if not present or not textual
*/
private static String getTextOrNull(JsonNode node, String fieldName) {
if (node == null) {
return null;
}
JsonNode fieldNode = node.path(fieldName);
if (fieldNode.isMissingNode() || fieldNode.isNull()) {
return null;
}
return fieldNode.asText(null);
}
// -------------------------------------------------------------------------
// Private helper methods for condition evaluation
// -------------------------------------------------------------------------
/**
* Evaluates equality between two objects.
*
* @param currentValue the current value
* @param expectedValue the expected value
* @return true if the values are equal
*/
private static boolean evaluateEquals(Object currentValue, Object expectedValue) {
return Objects.equals(currentValue, expectedValue);
}
/**
* Evaluates inequality between two objects.
*
* @param currentValue the current value
* @param expectedValue the expected value
* @return true if the values are not equal
*/
private static boolean evaluateNotEquals(Object currentValue, Object expectedValue) {
return !Objects.equals(currentValue, expectedValue);
}
/**
* Evaluates if the current value contains the expected value as a substring.
*
* @param currentValue the current value
* @param expectedValue the expected value
* @return true if currentValue contains expectedValue
*/
private static boolean evaluateContains(Object currentValue, Object expectedValue) {
if (currentValue == null) {
return false;
}
String currentStr = currentValue.toString();
String expectedStr = expectedValue != null ? expectedValue.toString() : "";
return currentStr.contains(expectedStr);
}
/**
* Evaluates if the current value is empty.
* <p>
* A value is considered empty if it is:
* </p>
* <ul>
* <li>{@code null}</li>
* <li>An empty string ({@code ""})</li>
* <li>A string containing only whitespace characters</li>
* </ul>
* <p>
* <b>Note:</b> Primitive types ({@code long}, {@code int}, {@code boolean}, etc.) are
* automatically boxed by Java when passed to this method. Their string representation
* (e.g., "123", "true", "false") is never blank, so they always return {@code false}.
* </p>
*
* @param currentValue the current value to check (primitives are auto-boxed)
* @return {@code true} if the value is null, empty, or blank; {@code false} otherwise
*/
private static boolean evaluateIsEmpty(Object currentValue) {
if (currentValue == null) {
return true;
}
String strValue = currentValue.toString();
return strValue.isBlank();
}
/**
* Evaluates if the current value is not empty.
* <p>
* A value is considered not empty if it is not null and contains
* at least one non-whitespace character.
* </p>
* <p>
* <b>Note:</b> Primitive types ({@code long}, {@code int}, {@code boolean}, etc.) are
* automatically boxed by Java when passed to this method. Their string representation
* is never blank, so they always return {@code true}.
* </p>
*
* @param currentValue the current value to check (primitives are auto-boxed)
* @return {@code true} if the value is not null and not blank; {@code false} otherwise
*/
private static boolean evaluateIsNotEmpty(Object currentValue) {
return !evaluateIsEmpty(currentValue);
}
/**
* Evaluates a comparison (greater than or less than).
*
* @param currentValue the current value
* @param expectedValue the expected value
* @param direction 1 for greater than, -1 for less than
* @return true if the comparison holds
*/
private static boolean evaluateComparison(Object currentValue, Object expectedValue,
int direction) {
if (currentValue instanceof Comparable<?> currentComp
&& expectedValue instanceof Comparable<?> expectedComp) {
int result = compareValues(currentComp, expectedComp);
return direction > 0 ? result > 0 : result < 0;
}
return false;
}
/**
* Evaluates a comparison with equality (greater/less than or equal).
*
* @param currentValue the current value
* @param expectedValue the expected value
* @param direction 1 for greater or equal, -1 for less or equal
* @return true if the comparison holds
*/
private static boolean evaluateComparisonOrEqual(Object currentValue, Object expectedValue,
int direction) {
if (currentValue instanceof Comparable<?> currentComp
&& expectedValue instanceof Comparable<?> expectedComp) {
int result = compareValues(currentComp, expectedComp);
return direction > 0 ? result >= 0 : result <= 0;
}
return false;
}
// -------------------------------------------------------------------------
// Redirection utility methods (backward-compatible with old and new structure)
// -------------------------------------------------------------------------
/**
* Default value returned when the redirection name cannot be determined.
*/
public static final String DEFAULT_REDIRECTION_NAME = "Unknown";
/**
* Retrieves the redirection name from a JSON node, supporting both old and new data structures.
* <p>
* This method provides backward compatibility by checking:
* </p>
* <ol>
* <li><b>New structure:</b> {@code parameters.name}</li>
* <li><b>Old structure:</b> {@code name} (directly on the node)</li>
* </ol>
* <p>
* If neither structure contains the name, returns {@value #DEFAULT_REDIRECTION_NAME}.
* </p>
*
* @param redirection the JSON node containing redirection data (may be null)
* @return the redirection name, or {@value #DEFAULT_REDIRECTION_NAME} if not found
*/
public static String getRedirectionName(JsonNode redirection) {
if (redirection == null) {
return DEFAULT_REDIRECTION_NAME;
}
// New structure: parameters.name
JsonNode parametersNode = redirection.get("parameters");
if (parametersNode != null && parametersNode.has("name")) {
return parametersNode.get("name").asText();
}
// Old structure: name directly
if (redirection.has("name")) {
return redirection.get("name").asText();
}
return DEFAULT_REDIRECTION_NAME;
}
/**
* Retrieves the target step from a JSON node, supporting both old and new data structures.
* <p>
* This method provides backward compatibility by checking:
* </p>
* <ol>
* <li><b>New structure:</b> {@code parameters.targetStep}</li>
* <li><b>Old structure:</b> {@code targetStep} (directly on the node)</li>
* </ol>
* <p>
* If neither structure contains the target step, returns {@code null}.
* </p>
*
* @param redirection the JSON node containing redirection data (may be null)
* @return the target step identifier, or {@code null} if not found
*/
public static String getTargetStep(JsonNode redirection) {
if (redirection == null) {
return null;
}
// New structure: parameters.targetStep
JsonNode parametersNode = redirection.get("parameters");
if (parametersNode != null && parametersNode.has("targetStep")) {
return parametersNode.get("targetStep").asText();
}
// Old structure: targetStep directly
if (redirection.has("targetStep")) {
return redirection.get("targetStep").asText();
}
return null;
}
}