This is an automated email from the ASF dual-hosted git repository. sergehuber pushed a commit to branch UNOMI-920-yaml-tostring in repository https://gitbox.apache.org/repos/asf/unomi.git
commit 3ce73e78198edb511e0313c1b9f2f84cdcb106a0 Author: Serge Huber <[email protected]> AuthorDate: Tue May 5 17:17:56 2026 +0200 UNOMI-920 Improve toString() methods with YAML formatting for debugging - Add YamlUtils (SnakeYaml) with YamlConvertible, YamlMapBuilder, circular reference detection (identity-based visited sets), and recursion depth limits. - Implement YAML-backed toString()/toYaml() on core API types extending Item / MetadataItem (Condition, ConditionType, Action, ActionType, Rule, Segment, Goal, Scoring, ScoringElement, Parameter, Metadata, etc.). - Add YamlUtilsTest coverage for builder, toYamlValue, and representative rules. Build and integration alignment: - unomi-api: snakeyaml + test dependencies; manage mockito-core version in BOM. - RESTParameter: use Object for defaultValue to match Parameter#getDefaultValue(). - RulesServiceImpl: avoid NPE when tracked parameter defaultValue is null before split. - itests: override awaitility to 3.1.6 for OSGi (Hamcrest 1.3 bundle); Karaf itests common logback exclusions; hamcrest bundle scope test. --- api/pom.xml | 22 + api/src/main/java/org/apache/unomi/api/Item.java | 62 ++- .../main/java/org/apache/unomi/api/Metadata.java | 46 +- .../java/org/apache/unomi/api/MetadataItem.java | 57 +- .../main/java/org/apache/unomi/api/Parameter.java | 64 ++- .../java/org/apache/unomi/api/actions/Action.java | 48 +- .../org/apache/unomi/api/actions/ActionType.java | 40 +- .../org/apache/unomi/api/conditions/Condition.java | 113 +++- .../apache/unomi/api/conditions/ConditionType.java | 43 +- .../main/java/org/apache/unomi/api/goals/Goal.java | 44 +- .../main/java/org/apache/unomi/api/rules/Rule.java | 46 +- .../org/apache/unomi/api/segments/Scoring.java | 38 +- .../apache/unomi/api/segments/ScoringElement.java | 47 +- .../org/apache/unomi/api/segments/Segment.java | 40 +- .../java/org/apache/unomi/api/utils/YamlUtils.java | 319 +++++++++++ .../org/apache/unomi/api/utils/YamlUtilsTest.java | 610 +++++++++++++++++++++ bom/pom.xml | 20 + itests/pom.xml | 22 +- pom.xml | 4 + .../apache/unomi/rest/models/RESTParameter.java | 6 +- .../services/impl/rules/RulesServiceImpl.java | 10 +- 21 files changed, 1656 insertions(+), 45 deletions(-) diff --git a/api/pom.xml b/api/pom.xml index af8c96220..4d9ed7fbd 100644 --- a/api/pom.xml +++ b/api/pom.xml @@ -73,6 +73,28 @@ <artifactId>jackson-databind</artifactId> <scope>provided</scope> </dependency> + <dependency> + <groupId>org.yaml</groupId> + <artifactId>snakeyaml</artifactId> + </dependency> + + <!-- Test Dependencies --> + <dependency> + <groupId>junit</groupId> + <artifactId>junit</artifactId> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.mockito</groupId> + <artifactId>mockito-core</artifactId> + <scope>test</scope> + </dependency> + <!-- SLF4J Implementation for Testing --> + <dependency> + <groupId>org.slf4j</groupId> + <artifactId>slf4j-simple</artifactId> + <scope>test</scope> + </dependency> </dependencies> <reporting> diff --git a/api/src/main/java/org/apache/unomi/api/Item.java b/api/src/main/java/org/apache/unomi/api/Item.java index de283ebe9..9836cf40d 100644 --- a/api/src/main/java/org/apache/unomi/api/Item.java +++ b/api/src/main/java/org/apache/unomi/api/Item.java @@ -17,14 +17,18 @@ package org.apache.unomi.api; +import org.apache.unomi.api.utils.YamlUtils; +import org.apache.unomi.api.utils.YamlUtils.YamlConvertible; +import org.apache.unomi.api.utils.YamlUtils.YamlMapBuilder; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.Serializable; -import java.util.HashMap; -import java.util.Map; +import java.util.*; import java.util.concurrent.ConcurrentHashMap; +import static org.apache.unomi.api.utils.YamlUtils.toYamlValue; + /** * A context server tracked entity. All tracked entities need to extend this class so as to provide the minimal information the context server needs to be able to track such * entities and operate on them. Items are persisted according to their type (structure) and identifier (identity). Of note, all Item subclasses <strong>must</strong> define a @@ -36,10 +40,10 @@ import java.util.concurrent.ConcurrentHashMap; * though scopes could span across sites depending on the desired analysis granularity). Scopes allow clients accessing the context server to filter data. The context server * defines a built-in scope ({@link Metadata#SYSTEM_SCOPE}) that clients can use to share data across scopes. */ -public abstract class Item implements Serializable { +public abstract class Item implements Serializable, YamlConvertible { private static final Logger LOGGER = LoggerFactory.getLogger(Item.class.getName()); - private static final long serialVersionUID = 7446061538573517071L; + private static final long serialVersionUID = 1217180125083162915L; private static final Map<Class,String> itemTypeCache = new ConcurrentHashMap<>(); @@ -150,4 +154,54 @@ public abstract class Item implements Serializable { public void setSystemMetadata(String key, Object value) { systemMetadata.put(key, value); } + + /** + * Converts this item to a Map structure for YAML output. + * Implements YamlConvertible interface with circular reference detection. + * + * @param visited set of already visited objects to prevent infinite recursion (may be null) + * @return a Map representation of this item + */ + @Override + public Map<String, Object> toYaml(Set<Object> visited, int maxDepth) { + if (maxDepth <= 0) { + return YamlMapBuilder.create() + .put("itemId", itemId) + .put("itemType", itemType) + .put("systemMetadata", "<max depth exceeded>") + .build(); + } + final Set<Object> visitedSet = visited != null ? visited : new HashSet<>(); + // Check if already visited - if so, we're being called from a child class via super.toYaml() + // OR it's a real circular reference. We can't distinguish, but since child classes + // (like Rule, ConditionType, etc.) all check for circular refs before calling super, + // if we're already visited here, it's safe to assume it's a super call, not a circular ref. + // If Item is directly serialized and encounters itself, the check would happen at the + // top level before nested processing, so this should be safe. + boolean alreadyVisited = visitedSet.contains(this); + if (!alreadyVisited) { + // First time seeing this object - add it to track for circular references + visitedSet.add(this); + } + try { + return YamlMapBuilder.create() + .put("itemId", itemId) // Always include, even if null, to reflect actual state + .put("itemType", itemType) // Always include, even if null, to reflect actual state + .putIfNotNull("scope", scope) + .putIfNotNull("version", version) + .putIfNotNull("systemMetadata", systemMetadata != null && !systemMetadata.isEmpty() ? toYamlValue(systemMetadata, visitedSet, maxDepth - 1) : null) + .build(); + } finally { + // Only remove if we added it (i.e., if it wasn't already visited) + if (!alreadyVisited) { + visitedSet.remove(this); + } + } + } + + @Override + public String toString() { + Map<String, Object> map = toYaml(); + return YamlUtils.format(map); + } } diff --git a/api/src/main/java/org/apache/unomi/api/Metadata.java b/api/src/main/java/org/apache/unomi/api/Metadata.java index 9a112e766..ec25c7fa8 100644 --- a/api/src/main/java/org/apache/unomi/api/Metadata.java +++ b/api/src/main/java/org/apache/unomi/api/Metadata.java @@ -17,16 +17,24 @@ package org.apache.unomi.api; +import org.apache.unomi.api.utils.YamlUtils; +import org.apache.unomi.api.utils.YamlUtils.YamlConvertible; +import org.apache.unomi.api.utils.YamlUtils.YamlMapBuilder; + import java.io.Serializable; +import java.util.HashSet; import java.util.LinkedHashSet; +import java.util.Map; import java.util.Set; +import static org.apache.unomi.api.utils.YamlUtils.circularRef; + /** * A class providing information about context server entities. * * @see MetadataItem */ -public class Metadata implements Comparable<Metadata>, Serializable { +public class Metadata implements Comparable<Metadata>, Serializable, YamlConvertible { private static final long serialVersionUID = 7446061538573517071L; @@ -279,5 +287,41 @@ public class Metadata implements Comparable<Metadata>, Serializable { return result; } + /** + * Converts this metadata to a Map structure for YAML output. + * Implements YamlConvertible interface with circular reference detection. + * + * @param visited set of already visited objects to prevent infinite recursion (may be null) + * @return a Map representation of this metadata + */ + @Override + public Map<String, Object> toYaml(Set<Object> visited, int maxDepth) { + if (visited != null && visited.contains(this)) { + return circularRef(); + } + final Set<Object> visitedSet = visited != null ? visited : new HashSet<>(); + visitedSet.add(this); + try { + return YamlMapBuilder.create() + .putIfNotNull("id", id) + .putIfNotNull("name", name) + .putIfNotNull("description", description) + .putIfNotNull("scope", scope) + .putIfNotEmpty("tags", tags) + .putIfNotEmpty("systemTags", systemTags) + .putIf("enabled", true, enabled) + .putIf("missingPlugins", true, missingPlugins) + .putIf("hidden", true, hidden) + .putIf("readOnly", true, readOnly) + .build(); + } finally { + visitedSet.remove(this); + } + } + @Override + public String toString() { + Map<String, Object> map = toYaml(); + return YamlUtils.format(map); + } } diff --git a/api/src/main/java/org/apache/unomi/api/MetadataItem.java b/api/src/main/java/org/apache/unomi/api/MetadataItem.java index 78a419863..35ef596a2 100644 --- a/api/src/main/java/org/apache/unomi/api/MetadataItem.java +++ b/api/src/main/java/org/apache/unomi/api/MetadataItem.java @@ -17,8 +17,16 @@ package org.apache.unomi.api; +import org.apache.unomi.api.utils.YamlUtils; +import org.apache.unomi.api.utils.YamlUtils.YamlMapBuilder; + import javax.xml.bind.annotation.XmlElement; import javax.xml.bind.annotation.XmlTransient; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import static org.apache.unomi.api.utils.YamlUtils.toYamlValue; /** * A superclass for all {@link Item}s that bear {@link Metadata}. @@ -31,7 +39,7 @@ public abstract class MetadataItem extends Item { } public MetadataItem(Metadata metadata) { - super(metadata.getId()); + super(metadata != null ? metadata.getId() : null); this.metadata = metadata; } @@ -54,7 +62,52 @@ public abstract class MetadataItem extends Item { @XmlTransient public String getScope() { - return metadata.getScope(); + if (metadata != null) { + return metadata.getScope(); + } + return scope; + } + + /** + * Converts this metadata item to a Map structure for YAML output. + * Merges fields from Item parent class and adds metadata field. + * Subclasses should override this method, call super.toYaml(visited), and add their specific fields. + * + * @param visited set of already visited objects to prevent infinite recursion (may be null) + * @return a Map representation of this metadata item + */ + @Override + public Map<String, Object> toYaml(Set<Object> visited, int maxDepth) { + if (maxDepth <= 0) { + return YamlMapBuilder.create() + .put("metadata", "<max depth exceeded>") + .build(); + } + final Set<Object> visitedSet = visited != null ? visited : new HashSet<>(); + // Check if already visited - if so, we're being called from a child class via super.toYaml() + // In that case, skip the circular reference check and just proceed + boolean alreadyVisited = visitedSet.contains(this); + if (!alreadyVisited) { + // Only check for circular references if this is the first time we're seeing this object + visitedSet.add(this); + } + try { + return YamlMapBuilder.create() + .mergeObject(super.toYaml(visitedSet, maxDepth)) + .putIfNotNull("metadata", metadata != null ? toYamlValue(metadata, visitedSet, maxDepth - 1) : null) + .build(); + } finally { + // Only remove if we added it (i.e., if it wasn't already visited) + if (!alreadyVisited) { + visitedSet.remove(this); + } + } } + + @Override + public String toString() { + Map<String, Object> map = toYaml(); + return YamlUtils.format(map); + } } diff --git a/api/src/main/java/org/apache/unomi/api/Parameter.java b/api/src/main/java/org/apache/unomi/api/Parameter.java index 4833c5a5f..425d86280 100644 --- a/api/src/main/java/org/apache/unomi/api/Parameter.java +++ b/api/src/main/java/org/apache/unomi/api/Parameter.java @@ -17,20 +17,27 @@ package org.apache.unomi.api; +import org.apache.unomi.api.utils.YamlUtils; +import org.apache.unomi.api.utils.YamlUtils.YamlConvertible; + import java.io.Serializable; +import java.util.Map; +import java.util.Set; + +import static org.apache.unomi.api.utils.YamlUtils.toYamlValue; /** * A representation of a condition parameter, to be used in the segment building UI to either select parameters from a * choicelist or to enter a specific value. */ -public class Parameter implements Serializable { +public class Parameter implements Serializable, YamlConvertible { - private static final long serialVersionUID = 7446061538573517071L; + private static final long serialVersionUID = 6019392686888941547L; - String id; - String type; - boolean multivalued = false; - String defaultValue = null; + private String id; + private String type; + private boolean multivalued; + private Object defaultValue; public Parameter() { } @@ -45,14 +52,26 @@ public class Parameter implements Serializable { return id; } + public void setId(String id) { + this.id = id; + } + public String getType() { return type; } + public void setType(String type) { + this.type = type; + } + public boolean isMultivalued() { return multivalued; } + public void setMultivalued(boolean multivalued) { + this.multivalued = multivalued; + } + /** * @param choiceListInitializerFilter a reference to a choicelist * @deprecated As of version 1.1.0-incubating @@ -62,11 +81,40 @@ public class Parameter implements Serializable { // Avoid errors when deploying old definitions } - public String getDefaultValue() { + public Object getDefaultValue() { return defaultValue; } - public void setDefaultValue(String defaultValue) { + public void setDefaultValue(Object defaultValue) { this.defaultValue = defaultValue; } + + /** + * Converts this parameter to a Map structure for YAML output. + * Implements YamlConvertible interface. + * + * @param visited set of already visited objects to prevent infinite recursion (may be null) + * @return a Map representation of this parameter + */ + @Override + public Map<String, Object> toYaml(Set<Object> visited, int maxDepth) { + if (maxDepth <= 0) { + return YamlUtils.YamlMapBuilder.create() + .put("id", id) + .put("validation", "<max depth exceeded>") + .build(); + } + return YamlUtils.YamlMapBuilder.create() + .putIfNotNull("id", id) + .putIfNotNull("type", type) + .putIf("multivalued", true, multivalued) + .putIfNotNull("defaultValue", defaultValue) + .build(); + } + + @Override + public String toString() { + return YamlUtils.format(toYaml()); + } + } diff --git a/api/src/main/java/org/apache/unomi/api/actions/Action.java b/api/src/main/java/org/apache/unomi/api/actions/Action.java index b3505bbe8..29c6927e1 100644 --- a/api/src/main/java/org/apache/unomi/api/actions/Action.java +++ b/api/src/main/java/org/apache/unomi/api/actions/Action.java @@ -18,18 +18,26 @@ package org.apache.unomi.api.actions; import org.apache.unomi.api.rules.Rule; +import org.apache.unomi.api.utils.YamlUtils; +import org.apache.unomi.api.utils.YamlUtils.YamlConvertible; +import org.apache.unomi.api.utils.YamlUtils.YamlMapBuilder; import javax.xml.bind.annotation.XmlElement; import javax.xml.bind.annotation.XmlTransient; import java.io.Serializable; import java.util.HashMap; +import java.util.HashSet; import java.util.Map; +import java.util.Set; + +import static org.apache.unomi.api.utils.YamlUtils.circularRef; +import static org.apache.unomi.api.utils.YamlUtils.toYamlValue; /** * An action that can be executed as a consequence of a {@link Rule} being triggered. An action is characterized by its associated {@link * ActionType} and parameter values. */ -public class Action implements Serializable { +public class Action implements Serializable, YamlConvertible { private ActionType actionType; private String actionTypeId; private Map<String, Object> parameterValues = new HashMap<>(); @@ -117,4 +125,42 @@ public class Action implements Serializable { parameterValues.put(name, value); } + /** + * Converts this action to a Map structure for YAML output. + * Implements YamlConvertible interface with circular reference detection. + * + * @param visited set of already visited objects to prevent infinite recursion (may be null) + * @return a Map representation of this action + */ + @Override + public Map<String, Object> toYaml(Set<Object> visited, int maxDepth) { + if (maxDepth <= 0) { + return YamlMapBuilder.create() + .put("type", actionTypeId != null ? actionTypeId : "Action") + .put("parameterValues", "<max depth exceeded>") + .build(); + } + if (visited != null && visited.contains(this)) { + return circularRef(); + } + final Set<Object> visitedSet = visited != null ? visited : new HashSet<>(); + visitedSet.add(this); + try { + YamlMapBuilder builder = YamlMapBuilder.create() + .put("type", actionTypeId != null ? actionTypeId : "Action"); + if (parameterValues != null && !parameterValues.isEmpty()) { + builder.put("parameterValues", toYamlValue(parameterValues, visitedSet, maxDepth - 1)); + } + return builder.build(); + } finally { + visitedSet.remove(this); + } + } + + @Override + public String toString() { + Map<String, Object> map = toYaml(); + return YamlUtils.format(map); + } + } diff --git a/api/src/main/java/org/apache/unomi/api/actions/ActionType.java b/api/src/main/java/org/apache/unomi/api/actions/ActionType.java index 5ff336d9f..e170c779b 100644 --- a/api/src/main/java/org/apache/unomi/api/actions/ActionType.java +++ b/api/src/main/java/org/apache/unomi/api/actions/ActionType.java @@ -20,14 +20,18 @@ package org.apache.unomi.api.actions; import org.apache.unomi.api.Metadata; import org.apache.unomi.api.MetadataItem; import org.apache.unomi.api.Parameter; +import org.apache.unomi.api.utils.YamlUtils.YamlConvertible; +import org.apache.unomi.api.utils.YamlUtils.YamlMapBuilder; -import java.util.ArrayList; -import java.util.List; +import java.util.*; + +import static org.apache.unomi.api.utils.YamlUtils.circularRef; +import static org.apache.unomi.api.utils.YamlUtils.toYamlValue; /** * A type definition for {@link Action}s. */ -public class ActionType extends MetadataItem { +public class ActionType extends MetadataItem implements YamlConvertible { public static final String ITEM_TYPE = "actionType"; private static final long serialVersionUID = -3522958600710010935L; @@ -101,4 +105,34 @@ public class ActionType extends MetadataItem { public int hashCode() { return itemId.hashCode(); } + + /** + * Converts this action type to a Map structure for YAML output. + * Implements YamlConvertible interface with circular reference detection. + * + * @param visited set of already visited objects to prevent infinite recursion (may be null) + * @return a Map representation of this action type + */ + @Override + public Map<String, Object> toYaml(Set<Object> visited, int maxDepth) { + if (maxDepth <= 0) { + return YamlMapBuilder.create() + .put("parameters", "<max depth exceeded>") + .build(); + } + if (visited != null && visited.contains(this)) { + return circularRef(); + } + final Set<Object> visitedSet = visited != null ? visited : new HashSet<>(); + visitedSet.add(this); + try { + return YamlMapBuilder.create() + .mergeObject(super.toYaml(visitedSet, maxDepth)) + .putIfNotNull("actionExecutor", actionExecutor) + .putIfNotEmpty("parameters", parameters != null ? (Collection<?>) toYamlValue(parameters, visitedSet, maxDepth - 1) : null) + .build(); + } finally { + visitedSet.remove(this); + } + } } diff --git a/api/src/main/java/org/apache/unomi/api/conditions/Condition.java b/api/src/main/java/org/apache/unomi/api/conditions/Condition.java index 526ff463f..812ee5862 100644 --- a/api/src/main/java/org/apache/unomi/api/conditions/Condition.java +++ b/api/src/main/java/org/apache/unomi/api/conditions/Condition.java @@ -17,16 +17,22 @@ package org.apache.unomi.api.conditions; +import org.apache.unomi.api.utils.YamlUtils; +import org.apache.unomi.api.utils.YamlUtils.YamlConvertible; +import org.apache.unomi.api.utils.YamlUtils.YamlMapBuilder; + import javax.xml.bind.annotation.XmlElement; import javax.xml.bind.annotation.XmlTransient; import java.io.Serializable; -import java.util.HashMap; -import java.util.Map; +import java.util.*; + +import static org.apache.unomi.api.utils.YamlUtils.circularRef; +import static org.apache.unomi.api.utils.YamlUtils.toYamlValue; /** * A set of elements that can be evaluated. */ -public class Condition implements Serializable { +public class Condition implements Serializable, YamlConvertible { private static final long serialVersionUID = 7584522402785053206L; ConditionType conditionType; @@ -65,7 +71,9 @@ public class Condition implements Serializable { */ public void setConditionType(ConditionType conditionType) { this.conditionType = conditionType; - this.conditionTypeId = conditionType.getItemId(); + if (conditionType != null) { + this.conditionTypeId = conditionType.getItemId(); + } } /** @@ -123,7 +131,7 @@ public class Condition implements Serializable { * @return the value of the specified parameter or {@code null} if no such parameter exists */ public Object getParameter(String name) { - return parameterValues.get(name); + return parameterValues != null ? parameterValues.get(name) : null; } /** @@ -156,13 +164,96 @@ public class Condition implements Serializable { return result; } + /** + * Converts this condition to a Map structure for YAML output with depth limiting. + * Implements YamlConvertible interface with circular reference detection and depth limiting + * to prevent StackOverflowError from extremely deep nested structures. + * + * @param visited set of already visited objects to prevent infinite recursion (may be null) + * @param maxDepth maximum recursion depth (prevents StackOverflowError from deep nesting) + * @return a Map representation of this condition + */ + @Override + public Map<String, Object> toYaml(Set<Object> visited, int maxDepth) { + if (maxDepth <= 0) { + return YamlMapBuilder.create() + .put("type", conditionTypeId != null ? conditionTypeId : "Condition") + .put("parameterValues", "<max depth exceeded>") + .build(); + } + if (visited != null && visited.contains(this)) { + return circularRef(); + } + final Set<Object> visitedSet = visited != null ? visited : new HashSet<>(); + visitedSet.add(this); + try { + YamlMapBuilder builder = YamlMapBuilder.create() + .put("type", conditionTypeId != null ? conditionTypeId : "Condition"); + if (parameterValues != null && !parameterValues.isEmpty()) { + builder.put("parameterValues", toYamlValue(parameterValues, visitedSet, maxDepth - 1)); + } + return builder.build(); + } finally { + visitedSet.remove(this); + } + } + + /** + * Creates a deep copy of this condition, including all nested conditions in parameter values. + * Recursively copies all nested conditions to avoid sharing references. + * + * @return a deep copy of this condition + */ + public Condition deepCopy() { + Condition copied = new Condition(); + if (this.conditionType != null) { + copied.setConditionType(this.conditionType); + } else if (this.conditionTypeId != null) { + copied.setConditionTypeId(this.conditionTypeId); + } + + // Deep copy parameter values + Map<String, Object> copiedParams = new HashMap<>(); + if (this.parameterValues != null) { + for (Map.Entry<String, Object> entry : this.parameterValues.entrySet()) { + Object value = entry.getValue(); + if (value instanceof Condition) { + // Recursively deep copy nested condition + copiedParams.put(entry.getKey(), ((Condition) value).deepCopy()); + } else if (value instanceof Collection) { + // Deep copy collection - preserve the collection type if possible + Collection<?> collection = (Collection<?>) value; + Collection<Object> copiedCollection; + if (collection instanceof List) { + copiedCollection = new ArrayList<>(); + } else { + // Fallback to ArrayList for other collection types + copiedCollection = new ArrayList<>(); + } + for (Object item : collection) { + if (item instanceof Condition) { + // Recursively deep copy nested condition + copiedCollection.add(((Condition) item).deepCopy()); + } else { + // Not a condition, add as-is (for non-condition values in collections) + copiedCollection.add(item); + } + } + copiedParams.put(entry.getKey(), copiedCollection); + } else { + // Primitive or other non-condition value, copy as-is + copiedParams.put(entry.getKey(), value); + } + } + } + copied.setParameterValues(copiedParams); + + return copied; + } + @Override public String toString() { - final StringBuilder sb = new StringBuilder("Condition{"); - sb.append("conditionType=").append(conditionType); - sb.append(", conditionTypeId='").append(conditionTypeId).append('\''); - sb.append(", parameterValues=").append(parameterValues); - sb.append('}'); - return sb.toString(); + Map<String, Object> map = toYaml(); + return YamlUtils.format(map); } } diff --git a/api/src/main/java/org/apache/unomi/api/conditions/ConditionType.java b/api/src/main/java/org/apache/unomi/api/conditions/ConditionType.java index c8a43225c..e8af9c41f 100644 --- a/api/src/main/java/org/apache/unomi/api/conditions/ConditionType.java +++ b/api/src/main/java/org/apache/unomi/api/conditions/ConditionType.java @@ -20,10 +20,14 @@ package org.apache.unomi.api.conditions; import org.apache.unomi.api.Metadata; import org.apache.unomi.api.MetadataItem; import org.apache.unomi.api.Parameter; +import org.apache.unomi.api.utils.YamlUtils.YamlConvertible; +import org.apache.unomi.api.utils.YamlUtils.YamlMapBuilder; import javax.xml.bind.annotation.XmlElement; -import java.util.ArrayList; -import java.util.List; +import java.util.*; + +import static org.apache.unomi.api.utils.YamlUtils.circularRef; +import static org.apache.unomi.api.utils.YamlUtils.toYamlValue; /** * ConditionTypes define new conditions that can be applied to items (for example to decide whether a rule needs to be triggered or if a profile is considered as taking part in a @@ -31,7 +35,7 @@ import java.util.List; * optimized by coding it. They may also be defined as combination of other conditions. A simple condition could be: “User is male”, while a more generic condition with * parameters may test whether a given property has a specific value: “User property x has value y”. */ -public class ConditionType extends MetadataItem { +public class ConditionType extends MetadataItem implements YamlConvertible { public static final String ITEM_TYPE = "conditionType"; private static final long serialVersionUID = -6965481691241954969L; @@ -142,4 +146,37 @@ public class ConditionType extends MetadataItem { public int hashCode() { return itemId.hashCode(); } + + /** + * Converts this condition type to a Map structure for YAML output. + * Implements YamlConvertible interface with circular reference detection. + * + * @param visited set of already visited objects to prevent infinite recursion (may be null) + * @return a Map representation of this condition type + */ + @Override + public Map<String, Object> toYaml(Set<Object> visited, int maxDepth) { + if (maxDepth <= 0) { + return YamlMapBuilder.create() + .put("parentCondition", "<max depth exceeded>") + .put("parameters", "<max depth exceeded>") + .build(); + } + if (visited != null && visited.contains(this)) { + return circularRef(); + } + final Set<Object> visitedSet = visited != null ? visited : new HashSet<>(); + visitedSet.add(this); + try { + return YamlMapBuilder.create() + .mergeObject(super.toYaml(visitedSet, maxDepth)) + .putIfNotNull("conditionEvaluator", conditionEvaluator) + .putIfNotNull("queryBuilder", queryBuilder) + .putIfNotNull("parentCondition", parentCondition != null ? toYamlValue(parentCondition, visitedSet, maxDepth - 1) : null) + .putIfNotEmpty("parameters", parameters != null ? (Collection<?>) toYamlValue(parameters, visitedSet, maxDepth - 1) : null) + .build(); + } finally { + visitedSet.remove(this); + } + } } diff --git a/api/src/main/java/org/apache/unomi/api/goals/Goal.java b/api/src/main/java/org/apache/unomi/api/goals/Goal.java index 1b2ff0e88..12694b3d7 100644 --- a/api/src/main/java/org/apache/unomi/api/goals/Goal.java +++ b/api/src/main/java/org/apache/unomi/api/goals/Goal.java @@ -21,6 +21,15 @@ import org.apache.unomi.api.Metadata; import org.apache.unomi.api.MetadataItem; import org.apache.unomi.api.campaigns.Campaign; import org.apache.unomi.api.conditions.Condition; +import org.apache.unomi.api.utils.YamlUtils.YamlConvertible; +import org.apache.unomi.api.utils.YamlUtils.YamlMapBuilder; + +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import static org.apache.unomi.api.utils.YamlUtils.circularRef; +import static org.apache.unomi.api.utils.YamlUtils.toYamlValue; /** * A tracked activity / action that can be accomplished by site (scope) visitors. These are tracked in general because they relate to specific business objectives or are @@ -32,7 +41,7 @@ import org.apache.unomi.api.conditions.Condition; * <li>audience filtering: any visitor is considered for scope-level goals while campaign-level goals only consider visitors who match the campaign's conditions * </ul> */ -public class Goal extends MetadataItem { +public class Goal extends MetadataItem implements YamlConvertible { public static final String ITEM_TYPE = "goal"; private static final long serialVersionUID = 6131648013470949983L; private Condition startEvent; @@ -87,4 +96,37 @@ public class Goal extends MetadataItem { public void setCampaignId(String campaignId) { this.campaignId = campaignId; } + + /** + * Converts this goal to a Map structure for YAML output. + * Implements YamlConvertible interface with circular reference detection. + * + * @param visited set of already visited objects to prevent infinite recursion (may be null) + * @return a Map representation of this goal + */ + @Override + public Map<String, Object> toYaml(Set<Object> visited, int maxDepth) { + if (maxDepth <= 0) { + return YamlMapBuilder.create() + .put("startEvent", "<max depth exceeded>") + .put("targetEvent", "<max depth exceeded>") + .put("campaignId", campaignId) + .build(); + } + if (visited != null && visited.contains(this)) { + return circularRef(); + } + final Set<Object> visitedSet = visited != null ? visited : new HashSet<>(); + visitedSet.add(this); + try { + return YamlMapBuilder.create() + .mergeObject(super.toYaml(visitedSet, maxDepth)) + .putIfNotNull("startEvent", startEvent != null ? toYamlValue(startEvent, visitedSet, maxDepth - 1) : null) + .putIfNotNull("targetEvent", targetEvent != null ? toYamlValue(targetEvent, visitedSet, maxDepth - 1) : null) + .putIfNotNull("campaignId", campaignId) + .build(); + } finally { + visitedSet.remove(this); + } + } } diff --git a/api/src/main/java/org/apache/unomi/api/rules/Rule.java b/api/src/main/java/org/apache/unomi/api/rules/Rule.java index 0aedfb123..63750c0d4 100644 --- a/api/src/main/java/org/apache/unomi/api/rules/Rule.java +++ b/api/src/main/java/org/apache/unomi/api/rules/Rule.java @@ -20,10 +20,15 @@ package org.apache.unomi.api.rules; import org.apache.unomi.api.*; import org.apache.unomi.api.actions.Action; import org.apache.unomi.api.conditions.Condition; +import org.apache.unomi.api.utils.YamlUtils.YamlConvertible; +import org.apache.unomi.api.utils.YamlUtils.YamlMapBuilder; -import java.util.List; +import java.util.*; import java.util.stream.Collectors; +import static org.apache.unomi.api.utils.YamlUtils.circularRef; +import static org.apache.unomi.api.utils.YamlUtils.toYamlValue; + /** * A conditional set of actions to be executed in response to incoming events. Triggering of rules is guarded by a condition: the rule is only triggered if the associated * condition ({@link #getCondition()}) is satisfied. Once a rule triggers, a list of actions ({@link #getActions()} can be performed as consequences. @@ -34,7 +39,7 @@ import java.util.stream.Collectors; * We could also specify a priority for our rule in case it needs to be executed before other ones when similar conditions match. This is accomplished using the * {@link #getPriority()} property. */ -public class Rule extends MetadataItem { +public class Rule extends MetadataItem implements YamlConvertible { /** * The Rule ITEM_TYPE. @@ -197,4 +202,41 @@ public class Rule extends MetadataItem { public void setPriority(int priority) { this.priority = priority; } + + /** + * Converts this rule to a Map structure for YAML output. + * Implements YamlConvertible interface with circular reference detection. + * + * @param visited set of already visited objects to prevent infinite recursion (may be null) + * @return a Map representation of this rule + */ + @Override + public Map<String, Object> toYaml(Set<Object> visited, int maxDepth) { + if (maxDepth <= 0) { + return YamlMapBuilder.create() + .put("condition", "<max depth exceeded>") + .put("actions", "<max depth exceeded>") + .put("priority", priority) + .build(); + } + if (visited != null && visited.contains(this)) { + return circularRef(); + } + final Set<Object> visitedSet = visited != null ? visited : new HashSet<>(); + visitedSet.add(this); + try { + return YamlMapBuilder.create() + .mergeObject(super.toYaml(visitedSet, maxDepth)) + .putIfNotNull("condition", condition != null ? toYamlValue(condition, visitedSet, maxDepth - 1) : null) + .putIfNotEmpty("actions", actions != null ? (Collection<?>) toYamlValue(actions, visitedSet, maxDepth - 1) : null) + .putIfNotEmpty("linkedItems", linkedItems) + .putIf("raiseEventOnlyOnceForProfile", true, raiseEventOnlyOnceForProfile) + .putIf("raiseEventOnlyOnceForSession", true, raiseEventOnlyOnceForSession) + .putIf("raiseEventOnlyOnce", true, raiseEventOnlyOnce) + .putIf("priority", priority, priority != 0) + .build(); + } finally { + visitedSet.remove(this); + } + } } diff --git a/api/src/main/java/org/apache/unomi/api/segments/Scoring.java b/api/src/main/java/org/apache/unomi/api/segments/Scoring.java index 6018fb376..3d82e6e7e 100644 --- a/api/src/main/java/org/apache/unomi/api/segments/Scoring.java +++ b/api/src/main/java/org/apache/unomi/api/segments/Scoring.java @@ -21,14 +21,19 @@ import org.apache.unomi.api.Item; import org.apache.unomi.api.Metadata; import org.apache.unomi.api.MetadataItem; import org.apache.unomi.api.Profile; +import org.apache.unomi.api.utils.YamlUtils.YamlConvertible; +import org.apache.unomi.api.utils.YamlUtils.YamlMapBuilder; -import java.util.List; +import java.util.*; + +import static org.apache.unomi.api.utils.YamlUtils.circularRef; +import static org.apache.unomi.api.utils.YamlUtils.toYamlValue; /** * A set of conditions associated with a value to assign to {@link Profile}s when matching so that the associated users can be scored along that * dimension. Each {@link ScoringElement} is evaluated and matching profiles' scores are incremented with the associated value. */ -public class Scoring extends MetadataItem { +public class Scoring extends MetadataItem implements YamlConvertible { /** * The Scoring ITEM_TYPE. * @@ -71,4 +76,33 @@ public class Scoring extends MetadataItem { this.elements = elements; } + /** + * Converts this scoring to a Map structure for YAML output. + * Implements YamlConvertible interface with circular reference detection. + * + * @param visited set of already visited objects to prevent infinite recursion (may be null) + * @return a Map representation of this scoring + */ + @Override + public Map<String, Object> toYaml(Set<Object> visited, int maxDepth) { + if (maxDepth <= 0) { + return YamlMapBuilder.create() + .put("elements", "<max depth exceeded>") + .build(); + } + if (visited != null && visited.contains(this)) { + return circularRef(); + } + final Set<Object> visitedSet = visited != null ? visited : new HashSet<>(); + visitedSet.add(this); + try { + return YamlMapBuilder.create() + .mergeObject(super.toYaml(visitedSet, maxDepth)) + .putIfNotEmpty("elements", elements != null ? (Collection<?>) toYamlValue(elements, visitedSet, maxDepth - 1) : null) + .build(); + } finally { + visitedSet.remove(this); + } + } + } diff --git a/api/src/main/java/org/apache/unomi/api/segments/ScoringElement.java b/api/src/main/java/org/apache/unomi/api/segments/ScoringElement.java index 1af5147fb..bfc5a21cc 100644 --- a/api/src/main/java/org/apache/unomi/api/segments/ScoringElement.java +++ b/api/src/main/java/org/apache/unomi/api/segments/ScoringElement.java @@ -18,13 +18,22 @@ package org.apache.unomi.api.segments; import org.apache.unomi.api.conditions.Condition; +import org.apache.unomi.api.utils.YamlUtils; +import org.apache.unomi.api.utils.YamlUtils.YamlConvertible; +import org.apache.unomi.api.utils.YamlUtils.YamlMapBuilder; import java.io.Serializable; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import static org.apache.unomi.api.utils.YamlUtils.circularRef; +import static org.apache.unomi.api.utils.YamlUtils.toYamlValue; /** * A scoring dimension along profiles can be evaluated and associated value to be assigned. */ -public class ScoringElement implements Serializable { +public class ScoringElement implements Serializable, YamlConvertible { private Condition condition; private int value; @@ -69,4 +78,40 @@ public class ScoringElement implements Serializable { public void setValue(int value) { this.value = value; } + + /** + * Converts this scoring element to a Map structure for YAML output. + * Implements YamlConvertible interface with circular reference detection. + * + * @param visited set of already visited objects to prevent infinite recursion (may be null) + * @return a Map representation of this scoring element + */ + @Override + public Map<String, Object> toYaml(Set<Object> visited, int maxDepth) { + if (maxDepth <= 0) { + return YamlMapBuilder.create() + .put("condition", "<max depth exceeded>") + .put("value", value) + .build(); + } + if (visited != null && visited.contains(this)) { + return circularRef(); + } + final Set<Object> visitedSet = visited != null ? visited : new HashSet<>(); + visitedSet.add(this); + try { + return YamlMapBuilder.create() + .putIfNotNull("condition", condition != null ? toYamlValue(condition, visitedSet, maxDepth - 1) : null) + .put("value", value) + .build(); + } finally { + visitedSet.remove(this); + } + } + + @Override + public String toString() { + Map<String, Object> map = toYaml(); + return YamlUtils.format(map); + } } diff --git a/api/src/main/java/org/apache/unomi/api/segments/Segment.java b/api/src/main/java/org/apache/unomi/api/segments/Segment.java index 4e0d33830..2729ec74d 100644 --- a/api/src/main/java/org/apache/unomi/api/segments/Segment.java +++ b/api/src/main/java/org/apache/unomi/api/segments/Segment.java @@ -22,13 +22,22 @@ import org.apache.unomi.api.Metadata; import org.apache.unomi.api.MetadataItem; import org.apache.unomi.api.Profile; import org.apache.unomi.api.conditions.Condition; +import org.apache.unomi.api.utils.YamlUtils.YamlConvertible; +import org.apache.unomi.api.utils.YamlUtils.YamlMapBuilder; + +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import static org.apache.unomi.api.utils.YamlUtils.circularRef; +import static org.apache.unomi.api.utils.YamlUtils.toYamlValue; /** * A dynamically evaluated group of similar profiles in order to categorize the associated users. To be considered part of a given segment, users must satisfies * the segment’s condition. If they match, users are automatically added to the segment. Similarly, if at any given point during, they cease to satisfy the segment’s condition, * they are automatically removed from it. */ -public class Segment extends MetadataItem { +public class Segment extends MetadataItem implements YamlConvertible { /** * The Segment ITEM_TYPE. @@ -72,4 +81,33 @@ public class Segment extends MetadataItem { this.condition = condition; } + /** + * Converts this segment to a Map structure for YAML output. + * Implements YamlConvertible interface with circular reference detection. + * + * @param visited set of already visited objects to prevent infinite recursion (may be null) + * @return a Map representation of this segment + */ + @Override + public Map<String, Object> toYaml(Set<Object> visited, int maxDepth) { + if (maxDepth <= 0) { + return YamlMapBuilder.create() + .put("condition", "<max depth exceeded>") + .build(); + } + if (visited != null && visited.contains(this)) { + return circularRef(); + } + final Set<Object> visitedSet = visited != null ? visited : new HashSet<>(); + visitedSet.add(this); + try { + return YamlMapBuilder.create() + .mergeObject(super.toYaml(visitedSet, maxDepth)) + .putIfNotNull("condition", condition != null ? toYamlValue(condition, visitedSet, maxDepth - 1) : null) + .build(); + } finally { + visitedSet.remove(this); + } + } + } diff --git a/api/src/main/java/org/apache/unomi/api/utils/YamlUtils.java b/api/src/main/java/org/apache/unomi/api/utils/YamlUtils.java new file mode 100644 index 000000000..664f63fb7 --- /dev/null +++ b/api/src/main/java/org/apache/unomi/api/utils/YamlUtils.java @@ -0,0 +1,319 @@ +/* + * 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.apache.unomi.api.utils; + +import org.yaml.snakeyaml.DumperOptions; +import org.yaml.snakeyaml.Yaml; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * YAML utilities using SnakeYaml with fluent API wrapper. + * Provides utilities for building YAML structures and formatting them via SnakeYaml. + */ +public class YamlUtils { + // SnakeYaml instance with configured options + private static final Yaml YAML_INSTANCE; + + static { + DumperOptions options = new DumperOptions(); + options.setIndent(2); + options.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK); + options.setPrettyFlow(true); + options.setDefaultScalarStyle(DumperOptions.ScalarStyle.PLAIN); + YAML_INSTANCE = new Yaml(options); + } + + /** + * Interface for objects that can convert themselves to YAML Map structures. + */ + public interface YamlConvertible { + /** + * Converts this object to a Map structure for YAML output with depth limiting. + * This method accepts an optional visited set to detect circular references and a max depth + * to prevent StackOverflowError from extremely deep nested structures. + * + * @param visited optional set of visited objects to detect circular references (may be null) + * @param maxDepth maximum recursion depth (prevents StackOverflowError from deep nesting) + * @return a Map representation of this object + */ + Map<String, Object> toYaml(Set<Object> visited, int maxDepth); + + /** + * Converts this object to a Map structure for YAML output. + * This method accepts an optional visited set to detect circular references. + * Uses a default max depth of 20 to prevent StackOverflowError. + * + * @param visited optional set of visited objects to detect circular references (may be null) + * @return a Map representation of this object + */ + default Map<String, Object> toYaml(Set<Object> visited) { + return toYaml(visited, 20); + } + + /** + * Converts this object to a Map structure for YAML output. + * This is a convenience method that calls toYaml(null, 20). + * + * @return a Map representation of this object + */ + default Map<String, Object> toYaml() { + return toYaml(null, 20); + } + } + + /** + * Fluent builder for creating YAML Map structures. + * Provides chaining methods to avoid repeating the map variable. + */ + public static class YamlMapBuilder { + private final Map<String, Object> map; + + private YamlMapBuilder() { + this.map = new LinkedHashMap<>(); + } + + /** + * Creates a new builder instance. + * + * @return a new YamlMapBuilder + */ + public static YamlMapBuilder create() { + return new YamlMapBuilder(); + } + + /** + * Adds a field if the value is not null. + * + * @param key the key (must not be null) + * @param value the value (only added if not null) + * @return this builder for chaining + * @throws NullPointerException if key is null + */ + public YamlMapBuilder putIfNotNull(String key, Object value) { + if (key == null) { + throw new NullPointerException("Key must not be null"); + } + if (value != null) { + map.put(key, value); + } + return this; + } + + /** + * Adds a field if the condition is true. + * + * @param key the key (must not be null) + * @param value the value (only added if condition is true) + * @param condition the condition + * @return this builder for chaining + * @throws NullPointerException if key is null + */ + public YamlMapBuilder putIf(String key, Object value, boolean condition) { + if (key == null) { + throw new NullPointerException("Key must not be null"); + } + if (condition) { + map.put(key, value); + } + return this; + } + + /** + * Adds a field unconditionally. + * + * @param key the key (must not be null) + * @param value the value + * @return this builder for chaining + * @throws NullPointerException if key is null + */ + public YamlMapBuilder put(String key, Object value) { + if (key == null) { + throw new NullPointerException("Key must not be null"); + } + map.put(key, value); + return this; + } + + /** + * Adds a field if the collection is not null and not empty. + * + * @param key the key (must not be null) + * @param collection the collection (only added if not null and not empty) + * @return this builder for chaining + * @throws NullPointerException if key is null + */ + public YamlMapBuilder putIfNotEmpty(String key, java.util.Collection<?> collection) { + if (key == null) { + throw new NullPointerException("Key must not be null"); + } + if (collection != null && !collection.isEmpty()) { + map.put(key, collection); + } + return this; + } + + /** + * Merges all fields from a Map into this builder. + * This is useful for inheritance where subclasses want to include parent class fields. + * + * Usage in subclasses: + * <pre> + * return YamlMapBuilder.create() + * .mergeObject(super.toYaml(visitedSet)) + * .putIfNotNull("field", value) + * .build(); + * </pre> + * + * @param objectMap the Map containing fields to merge (may be null, in which case nothing is merged) + * @return this builder for chaining + */ + public YamlMapBuilder mergeObject(Map<String, Object> objectMap) { + if (objectMap != null) { + objectMap.forEach(map::put); + } + return this; + } + + /** + * Builds and returns a defensive copy of the map. + * + * @return a new LinkedHashMap containing the built entries + */ + public Map<String, Object> build() { + return new LinkedHashMap<>(map); + } + } + + /** + * Converts a Set to a sorted List for YAML output. + * + * @param set the set to convert + * @return a sorted list, or null if the set is null or empty + */ + public static <T extends Comparable<T>> List<T> setToSortedList(Set<T> set) { + if (set == null || set.isEmpty()) { + return null; + } + return set.stream().sorted().collect(Collectors.toList()); + } + + /** + * Converts a Set to a sorted List using a mapper function. + * + * @param set the set to convert + * @param mapper the mapper function (must not be null) + * @return a sorted list, or null if the set is null or empty + * @throws NullPointerException if mapper is null + */ + public static <T, R extends Comparable<R>> List<R> setToSortedList(Set<T> set, Function<T, R> mapper) { + if (mapper == null) { + throw new NullPointerException("Mapper function must not be null"); + } + if (set == null || set.isEmpty()) { + return null; + } + return set.stream().map(mapper).sorted().collect(Collectors.toList()); + } + + /** + * Converts a value to YAML-compatible format, handling nested structures. + * For objects that implement YamlConvertible, circular reference detection is + * handled by passing the visited set to their toYaml() implementation. + * + * @param value the value to convert + * @param visited set of visited objects for circular reference detection (may be null) + * @return the converted value + */ + public static Object toYamlValue(Object value, Set<Object> visited) { + return toYamlValue(value, visited, Integer.MAX_VALUE); + } + + /** + * Converts a value to YAML-compatible format with depth limiting to prevent StackOverflowError. + * For objects that implement YamlConvertible, circular reference detection is + * handled by passing the visited set to their toYaml() implementation. + * + * @param value the value to convert + * @param visited set of visited objects for circular reference detection (may be null) + * @param maxDepth maximum recursion depth (prevents StackOverflowError from deep nesting) + * @return the converted value, or a placeholder if max depth exceeded + */ + public static Object toYamlValue(Object value, Set<Object> visited, int maxDepth) { + if (maxDepth <= 0) { + return "<max depth exceeded>"; + } + if (value == null) { + return null; + } + if (value instanceof YamlConvertible) { + // For YamlConvertible, get the Map and then process it as a Map to ensure sorting + // Pass maxDepth - 1 to the toYaml method to continue depth limiting + Map<String, Object> result = ((YamlConvertible) value).toYaml(visited, maxDepth - 1); + // Process the result as a Map to ensure it's sorted (this handles both sorting and recursive processing) + return toYamlValue(result, visited, maxDepth - 1); + } + if (value instanceof List) { + return ((List<?>) value).stream() + .map(item -> toYamlValue(item, visited, maxDepth - 1)) + .collect(Collectors.toList()); + } + if (value instanceof Map) { + Map<?, ?> inputMap = (Map<?, ?>) value; + Map<String, Object> result = new LinkedHashMap<>(); + + if (!inputMap.isEmpty()) { + // Sort entries alphabetically by key string representation + inputMap.entrySet().stream() + .sorted((e1, e2) -> String.valueOf(e1.getKey()).compareTo(String.valueOf(e2.getKey()))) + .forEach(entry -> + result.put(String.valueOf(entry.getKey()), toYamlValue(entry.getValue(), visited, maxDepth - 1))); + } + return result; + } + return value; + } + + + /** + * Formats a value as YAML using SnakeYaml. + * This is a convenience method that delegates to SnakeYaml. + * + * @param value the value to format + * @return YAML string representation + */ + public static String format(Object value) { + return YAML_INSTANCE.dump(value); + } + + /** + * Creates a circular reference marker map. + * + * @return a map indicating a circular reference + */ + public static Map<String, Object> circularRef() { + return YamlMapBuilder.create() + .put("$ref", "circular") + .build(); + } +} diff --git a/api/src/test/java/org/apache/unomi/api/utils/YamlUtilsTest.java b/api/src/test/java/org/apache/unomi/api/utils/YamlUtilsTest.java new file mode 100644 index 000000000..c40c066ca --- /dev/null +++ b/api/src/test/java/org/apache/unomi/api/utils/YamlUtilsTest.java @@ -0,0 +1,610 @@ +/* + * 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.apache.unomi.api.utils; + +import org.apache.unomi.api.Metadata; +import org.apache.unomi.api.actions.Action; +import org.apache.unomi.api.conditions.Condition; +import org.apache.unomi.api.rules.Rule; +import org.junit.Test; + +import java.util.*; + +import static org.junit.Assert.*; + +/** + * Unit tests for YamlUtils fluent API. + * Tests focus on our fluent API, not SnakeYaml's implementation. + */ +public class YamlUtilsTest { + + @Test + public void testYamlMapBuilderCreate() { + YamlUtils.YamlMapBuilder builder = YamlUtils.YamlMapBuilder.create(); + assertNotNull("Builder should be created", builder); + } + + @Test + public void testYamlMapBuilderPut() { + Map<String, Object> map = YamlUtils.YamlMapBuilder.create() + .put("key1", "value1") + .put("key2", 42) + .build(); + assertEquals("First value should be set", "value1", map.get("key1")); + assertEquals("Second value should be set", 42, map.get("key2")); + } + + @Test + public void testYamlMapBuilderPutIfNotNull() { + Map<String, Object> map = YamlUtils.YamlMapBuilder.create() + .putIfNotNull("key1", "value1") + .putIfNotNull("key2", null) + .putIfNotNull("key3", "value3") + .build(); + assertEquals("Non-null value should be set", "value1", map.get("key1")); + assertFalse("Null value should not be set", map.containsKey("key2")); + assertEquals("Another non-null value should be set", "value3", map.get("key3")); + } + + @Test + public void testYamlMapBuilderPutIf() { + Map<String, Object> map = YamlUtils.YamlMapBuilder.create() + .putIf("key1", "value1", true) + .putIf("key2", "value2", false) + .putIf("key3", "value3", true) + .build(); + assertEquals("Value with true condition should be set", "value1", map.get("key1")); + assertFalse("Value with false condition should not be set", map.containsKey("key2")); + assertEquals("Another value with true condition should be set", "value3", map.get("key3")); + } + + @Test + public void testYamlMapBuilderPutIfNotEmpty() { + Map<String, Object> map = YamlUtils.YamlMapBuilder.create() + .putIfNotEmpty("key1", Arrays.asList("a", "b")) + .putIfNotEmpty("key2", Collections.emptyList()) + .putIfNotEmpty("key3", null) + .putIfNotEmpty("key4", Arrays.asList("c")) + .build(); + assertTrue("Non-empty collection should be set", map.containsKey("key1")); + assertFalse("Empty collection should not be set", map.containsKey("key2")); + assertFalse("Null collection should not be set", map.containsKey("key3")); + assertTrue("Another non-empty collection should be set", map.containsKey("key4")); + } + + @Test + public void testYamlMapBuilderChaining() { + Map<String, Object> map = YamlUtils.YamlMapBuilder.create() + .put("a", 1) + .putIfNotNull("b", "value") + .putIf("c", 3, true) + .putIfNotEmpty("d", Arrays.asList(1, 2)) + .build(); + assertEquals("All valid entries should be added", 4, map.size()); + } + + @Test + public void testYamlMapBuilderNullKeyThrowsException() { + try { + YamlUtils.YamlMapBuilder.create().put(null, "value"); + fail("Null key should throw NullPointerException"); + } catch (NullPointerException e) { + // Expected + } + } + + @Test + public void testYamlMapBuilderNullKeyInPutIfNotNull() { + try { + YamlUtils.YamlMapBuilder.create().putIfNotNull(null, "value"); + fail("Null key in putIfNotNull should throw NullPointerException"); + } catch (NullPointerException e) { + // Expected + } + } + + @Test + public void testYamlMapBuilderNullKeyInPutIf() { + try { + YamlUtils.YamlMapBuilder.create().putIf(null, "value", true); + fail("Null key in putIf should throw NullPointerException"); + } catch (NullPointerException e) { + // Expected + } + } + + @Test + public void testYamlMapBuilderNullKeyInPutIfNotEmpty() { + try { + YamlUtils.YamlMapBuilder.create().putIfNotEmpty(null, Arrays.asList(1)); + fail("Null key in putIfNotEmpty should throw NullPointerException"); + } catch (NullPointerException e) { + // Expected + } + } + + @Test + public void testYamlMapBuilderBuildReturnsNewMap() { + YamlUtils.YamlMapBuilder builder = YamlUtils.YamlMapBuilder.create(); + builder.put("key", "value"); + Map<String, Object> map1 = builder.build(); + Map<String, Object> map2 = builder.build(); + assertNotSame("Each build() should return a new map", map1, map2); + assertEquals("Both maps should have same content", map1, map2); + } + + @Test + public void testSetToSortedList() { + Set<String> set = new LinkedHashSet<>(Arrays.asList("zebra", "apple", "banana")); + List<String> result = YamlUtils.setToSortedList(set); + assertNotNull("Result should not be null", result); + assertEquals("Set should be converted to sorted list", Arrays.asList("apple", "banana", "zebra"), result); + } + + @Test + public void testSetToSortedListNull() { + List<String> result = YamlUtils.setToSortedList((Set<String>) null); + assertNull("Null set should return null", result); + } + + @Test + public void testSetToSortedListEmpty() { + List<String> result = YamlUtils.setToSortedList(Collections.<String>emptySet()); + assertNull("Empty set should return null", result); + } + + @Test + public void testSetToSortedListWithMapper() { + Set<Integer> set = new LinkedHashSet<>(Arrays.asList(3, 1, 2)); + List<String> result = YamlUtils.setToSortedList(set, String::valueOf); + assertNotNull("Result should not be null", result); + assertEquals("Set should be converted to sorted list using mapper", Arrays.asList("1", "2", "3"), result); + } + + @Test + public void testSetToSortedListWithMapperNull() { + try { + YamlUtils.<Integer, String>setToSortedList(Collections.singleton(1), null); + fail("Null mapper should throw NullPointerException"); + } catch (NullPointerException e) { + // Expected + } + } + + @Test + public void testSetToSortedListWithMapperNullSet() { + List<String> result = YamlUtils.setToSortedList(null, String::valueOf); + assertNull("Null set should return null even with mapper", result); + } + + @Test + public void testToYamlValueWithYamlConvertible() { + YamlUtils.YamlConvertible convertible = (visited, maxDepth) -> { + Map<String, Object> map = new LinkedHashMap<>(); + map.put("test", "value"); + return map; + }; + Set<Object> visited = new HashSet<>(); + Object result = YamlUtils.toYamlValue(convertible, visited); + assertTrue("YamlConvertible should be converted to Map", result instanceof Map); + Map<?, ?> map = (Map<?, ?>) result; + assertEquals("Converted map should contain test value", "value", map.get("test")); + } + + @Test + public void testToYamlValueWithList() { + List<Object> list = Arrays.asList("a", "b", "c"); + Set<Object> visited = new HashSet<>(); + Object result = YamlUtils.toYamlValue(list, visited); + assertTrue("List should remain a List", result instanceof List); + assertEquals("List should be unchanged", list, result); + } + + @Test + public void testToYamlValueWithMap() { + Map<String, Object> map = new LinkedHashMap<>(); + map.put("key", "value"); + Set<Object> visited = new HashSet<>(); + Object result = YamlUtils.toYamlValue(map, visited); + assertTrue("Map should remain a Map", result instanceof Map); + assertEquals("Map should contain key-value", "value", ((Map<?, ?>) result).get("key")); + } + + @Test + public void testToYamlValueWithNull() { + Set<Object> visited = new HashSet<>(); + Object result = YamlUtils.toYamlValue(null, visited); + assertNull("Null should return null", result); + } + + @Test + public void testToYamlValueWithPrimitive() { + Set<Object> visited = new HashSet<>(); + Object result = YamlUtils.toYamlValue(42, visited); + assertEquals("Primitive should remain unchanged", 42, result); + } + + @Test + public void testCircularRef() { + Map<String, Object> result = YamlUtils.circularRef(); + assertNotNull("circularRef should return a map", result); + assertEquals("Should contain $ref: circular", "circular", result.get("$ref")); + assertEquals("Should have only one entry", 1, result.size()); + } + + @Test + public void testFormatBasic() { + // Just verify format() works - we don't test SnakeYaml's output format + Map<String, Object> map = new LinkedHashMap<>(); + map.put("key", "value"); + String result = YamlUtils.format(map); + assertNotNull("Format should return a string", result); + assertTrue("Format should contain key", result.contains("key")); + assertTrue("Format should contain value", result.contains("value")); + } + + // ========== Circular Reference Detection Tests ========== + + @Test + public void testRuleInheritanceChainNoCircularRef() { + // Test that Rule -> MetadataItem -> Item inheritance chain doesn't produce false circular refs + Rule rule = new Rule(); + rule.setItemId("test-rule"); + Metadata metadata = new Metadata("test-rule"); + metadata.setScope("systemscope"); + rule.setMetadata(metadata); + + Condition condition = new Condition(); + condition.setConditionTypeId("testCondition"); + rule.setCondition(condition); + + Map<String, Object> result = rule.toYaml(null); + assertNotNull("Rule should serialize to YAML", result); + assertFalse("Should not contain circular reference marker", result.containsKey("$ref")); + assertTrue("Should contain condition", result.containsKey("condition")); + assertTrue("Should contain itemId from Item parent", result.containsKey("itemId")); + assertTrue("Should contain metadata from MetadataItem parent", result.containsKey("metadata")); + } + + @Test + public void testRuleWithCircularReferenceInCondition() { + // Test that a real circular reference (Rule referenced in condition's parameterValues) is detected + Rule rule = new Rule(); + rule.setItemId("test-rule"); + Metadata metadata = new Metadata("test-rule"); + rule.setMetadata(metadata); + + Condition condition = new Condition(); + condition.setConditionTypeId("testCondition"); + // Create a circular reference: condition's parameterValues contains the rule itself + condition.getParameterValues().put("referencedRule", rule); + rule.setCondition(condition); + + Map<String, Object> result = rule.toYaml(null); + assertNotNull("Rule should serialize to YAML", result); + assertTrue("Should contain condition", result.containsKey("condition")); + + // Check that the circular reference is detected in the condition's parameterValues + Map<String, Object> conditionMap = (Map<String, Object>) result.get("condition"); + assertNotNull("Condition should be serialized", conditionMap); + Map<String, Object> paramValues = (Map<String, Object>) conditionMap.get("parameterValues"); + assertNotNull("Parameter values should exist", paramValues); + Map<String, Object> circularRef = (Map<String, Object>) paramValues.get("referencedRule"); + assertNotNull("Circular reference should be detected", circularRef); + assertEquals("Should contain circular reference marker", "circular", circularRef.get("$ref")); + } + + @Test + public void testRuleWithCircularReferenceInActions() { + // Test circular reference in actions list + Rule rule = new Rule(); + rule.setItemId("test-rule"); + Metadata metadata = new Metadata("test-rule"); + rule.setMetadata(metadata); + + Action action = new Action(); + action.setActionTypeId("testAction"); + // Create circular reference: action's parameterValues contains the rule + action.getParameterValues().put("triggeringRule", rule); + rule.setActions(Collections.singletonList(action)); + + Map<String, Object> result = rule.toYaml(null); + assertNotNull("Rule should serialize to YAML", result); + assertTrue("Should contain actions", result.containsKey("actions")); + + List<?> actions = (List<?>) result.get("actions"); + assertNotNull("Actions list should exist", actions); + assertEquals("Should have one action", 1, actions.size()); + + Map<String, Object> actionMap = (Map<String, Object>) actions.get(0); + Map<String, Object> paramValues = (Map<String, Object>) actionMap.get("parameterValues"); + assertNotNull("Parameter values should exist", paramValues); + Map<String, Object> circularRef = (Map<String, Object>) paramValues.get("triggeringRule"); + assertNotNull("Circular reference should be detected", circularRef); + assertEquals("Should contain circular reference marker", "circular", circularRef.get("$ref")); + } + + @Test + public void testNestedCircularReference() { + // Test nested circular reference: Rule -> Condition -> nested Condition -> Rule + Rule rule = new Rule(); + rule.setItemId("test-rule"); + Metadata metadata = new Metadata("test-rule"); + rule.setMetadata(metadata); + + Condition outerCondition = new Condition(); + outerCondition.setConditionTypeId("outerCondition"); + + Condition nestedCondition = new Condition(); + nestedCondition.setConditionTypeId("nestedCondition"); + // Nested condition references the rule + nestedCondition.getParameterValues().put("ruleRef", rule); + + // Outer condition contains nested condition + outerCondition.getParameterValues().put("nested", nestedCondition); + rule.setCondition(outerCondition); + + Map<String, Object> result = rule.toYaml(null); + assertNotNull("Rule should serialize to YAML", result); + + // Navigate through the nested structure + Map<String, Object> conditionMap = (Map<String, Object>) result.get("condition"); + Map<String, Object> paramValues = (Map<String, Object>) conditionMap.get("parameterValues"); + Map<String, Object> nestedConditionMap = (Map<String, Object>) paramValues.get("nested"); + Map<String, Object> nestedParamValues = (Map<String, Object>) nestedConditionMap.get("parameterValues"); + Map<String, Object> circularRef = (Map<String, Object>) nestedParamValues.get("ruleRef"); + + assertNotNull("Circular reference should be detected in nested structure", circularRef); + assertEquals("Should contain circular reference marker", "circular", circularRef.get("$ref")); + } + + @Test + public void testMultipleCircularReferences() { + // Test multiple circular references to the same object + Rule rule = new Rule(); + rule.setItemId("test-rule"); + Metadata metadata = new Metadata("test-rule"); + rule.setMetadata(metadata); + + Condition condition = new Condition(); + condition.setConditionTypeId("testCondition"); + // Multiple references to the same rule + condition.getParameterValues().put("rule1", rule); + condition.getParameterValues().put("rule2", rule); + condition.getParameterValues().put("rule3", rule); + rule.setCondition(condition); + + Map<String, Object> result = rule.toYaml(null); + Map<String, Object> conditionMap = (Map<String, Object>) result.get("condition"); + Map<String, Object> paramValues = (Map<String, Object>) conditionMap.get("parameterValues"); + + // All three references should show circular ref + for (String key : Arrays.asList("rule1", "rule2", "rule3")) { + Map<String, Object> circularRef = (Map<String, Object>) paramValues.get(key); + assertNotNull("Circular reference should be detected for " + key, circularRef); + assertEquals("Should contain circular reference marker for " + key, "circular", circularRef.get("$ref")); + } + } + + + @Test + public void testCircularReferenceInList() { + // Test circular reference in a list + Rule rule = new Rule(); + rule.setItemId("test-rule"); + Metadata metadata = new Metadata("test-rule"); + rule.setMetadata(metadata); + + Condition condition = new Condition(); + condition.setConditionTypeId("testCondition"); + // List containing the rule itself + condition.getParameterValues().put("ruleList", Arrays.asList(rule, "other", rule)); + rule.setCondition(condition); + + Map<String, Object> result = rule.toYaml(null); + Map<String, Object> conditionMap = (Map<String, Object>) result.get("condition"); + Map<String, Object> paramValues = (Map<String, Object>) conditionMap.get("parameterValues"); + List<?> ruleList = (List<?>) paramValues.get("ruleList"); + + assertNotNull("Rule list should exist", ruleList); + assertEquals("List should have 3 elements", 3, ruleList.size()); + + // First element should be circular ref + Map<String, Object> circularRef1 = (Map<String, Object>) ruleList.get(0); + assertEquals("First element should be circular ref", "circular", circularRef1.get("$ref")); + + // Second element should be string + assertEquals("Second element should be string", "other", ruleList.get(1)); + + // Third element should also be circular ref + Map<String, Object> circularRef2 = (Map<String, Object>) ruleList.get(2); + assertEquals("Third element should be circular ref", "circular", circularRef2.get("$ref")); + } + + @Test + public void testCircularReferenceInNestedMap() { + // Test circular reference in nested map structure + Rule rule = new Rule(); + rule.setItemId("test-rule"); + Metadata metadata = new Metadata("test-rule"); + rule.setMetadata(metadata); + + Condition condition = new Condition(); + condition.setConditionTypeId("testCondition"); + // Nested map containing the rule + Map<String, Object> nestedMap = new HashMap<>(); + nestedMap.put("level1", new HashMap<String, Object>() {{ + put("level2", new HashMap<String, Object>() {{ + put("rule", rule); + }}); + }}); + condition.getParameterValues().put("nested", nestedMap); + rule.setCondition(condition); + + Map<String, Object> result = rule.toYaml(null); + Map<String, Object> conditionMap = (Map<String, Object>) result.get("condition"); + Map<String, Object> paramValues = (Map<String, Object>) conditionMap.get("parameterValues"); + Map<String, Object> nested = (Map<String, Object>) paramValues.get("nested"); + Map<String, Object> level1 = (Map<String, Object>) nested.get("level1"); + Map<String, Object> level2 = (Map<String, Object>) level1.get("level2"); + Map<String, Object> circularRef = (Map<String, Object>) level2.get("rule"); + + assertNotNull("Circular reference should be detected in nested map", circularRef); + assertEquals("Should contain circular reference marker", "circular", circularRef.get("$ref")); + } + + @Test + public void testNoFalseCircularRefInInheritance() { + // Test that inheritance chain (Rule -> MetadataItem -> Item) doesn't create false circular refs + // This is the main bug we're fixing + Rule rule = new Rule(); + rule.setItemId("test-rule"); + Metadata metadata = new Metadata("test-rule"); + metadata.setScope("systemscope"); + rule.setMetadata(metadata); + + Condition condition = new Condition(); + condition.setConditionTypeId("unavailableConditionType"); + condition.getParameterValues().put("comparisonOperator", "equals"); + condition.getParameterValues().put("propertyName", "testProperty"); + condition.getParameterValues().put("propertyValue", "testValue"); + rule.setCondition(condition); + + Action action = new Action(); + action.setActionTypeId("test"); + rule.setActions(Collections.singletonList(action)); + + Map<String, Object> result = rule.toYaml(null); + + // Should NOT contain $ref: circular at the top level + assertNotNull("Rule should serialize", result); + assertFalse("Should not have false circular reference at top level", + result.containsKey("$ref") && "circular".equals(result.get("$ref"))); + + // Should contain all expected fields from inheritance chain + assertTrue("Should contain itemId from Item", result.containsKey("itemId")); + assertTrue("Should contain itemType from Item", result.containsKey("itemType")); + assertEquals("itemType should be 'rule'", "rule", result.get("itemType")); + assertTrue("Should contain metadata from MetadataItem", result.containsKey("metadata")); + assertTrue("Should contain condition", result.containsKey("condition")); + assertTrue("Should contain actions", result.containsKey("actions")); + + // Verify condition structure + Map<String, Object> conditionMap = (Map<String, Object>) result.get("condition"); + assertNotNull("Condition should be present", conditionMap); + assertEquals("Condition should have correct type", "unavailableConditionType", conditionMap.get("type")); + + // Verify actions structure + List<?> actions = (List<?>) result.get("actions"); + assertNotNull("Actions should be present", actions); + assertEquals("Should have one action", 1, actions.size()); + } + + @Test + public void testItemTypeIsAlwaysIncluded() { + // Test that itemType is always included in YAML output, even if null + // This reflects the actual state of the object + Rule rule = new Rule(); + Metadata metadata = new Metadata("test-id"); + metadata.setScope("systemscope"); + rule.setMetadata(metadata); + + Map<String, Object> result = rule.toYaml(null); + + // itemType should always be present in output (set in Item constructor for Rule) + assertTrue("itemType should be included", result.containsKey("itemType")); + assertEquals("itemType should be 'rule'", "rule", result.get("itemType")); + + // itemId should also always be included + assertTrue("itemId should be included", result.containsKey("itemId")); + } + + @Test + public void testItemIdAndItemTypeIncludedEvenWhenNull() { + // Test that itemId and itemType are always included, even when null + // This ensures YAML output reflects the actual state of the object + Rule rule = new Rule(); + // Explicitly set itemId and itemType to null to test null handling + rule.setItemId(null); + rule.setItemType(null); + + Map<String, Object> result = rule.toYaml(null); + + // Both should be included even if null + assertTrue("itemId should be included even when null", result.containsKey("itemId")); + assertNull("itemId should be null", result.get("itemId")); + + assertTrue("itemType should be included even when null", result.containsKey("itemType")); + assertNull("itemType should be null", result.get("itemType")); + } + + @Test + public void testItemIdFromMetadata() { + // Test that itemId is set from metadata and included in YAML + Rule rule = new Rule(); + Metadata metadata = new Metadata("test-rule-id"); + metadata.setScope("systemscope"); + rule.setMetadata(metadata); + + Map<String, Object> result = rule.toYaml(null); + + // itemId should be set from metadata.getId() + assertTrue("itemId should be included when set from metadata", result.containsKey("itemId")); + assertEquals("itemId should match metadata id", "test-rule-id", result.get("itemId")); + } + + @Test + public void testVisitedSetIsSharedCorrectly() { + // Test that visited set is properly shared across nested calls + Rule rule1 = new Rule(); + rule1.setItemId("rule1"); + rule1.setMetadata(new Metadata("rule1")); + + Rule rule2 = new Rule(); + rule2.setItemId("rule2"); + rule2.setMetadata(new Metadata("rule2")); + + // rule1 references rule2, rule2 references rule1 (mutual circular reference) + Condition condition1 = new Condition(); + condition1.setConditionTypeId("test"); + condition1.getParameterValues().put("otherRule", rule2); + rule1.setCondition(condition1); + + Condition condition2 = new Condition(); + condition2.setConditionTypeId("test"); + condition2.getParameterValues().put("otherRule", rule1); + rule2.setCondition(condition2); + + // Serialize rule1 - should detect circular ref when it encounters rule2 which references rule1 + Map<String, Object> result1 = rule1.toYaml(null); + assertNotNull("Rule1 should serialize", result1); + + Map<String, Object> conditionMap1 = (Map<String, Object>) result1.get("condition"); + Map<String, Object> paramValues1 = (Map<String, Object>) conditionMap1.get("parameterValues"); + Map<String, Object> rule2Ref = (Map<String, Object>) paramValues1.get("otherRule"); + + // rule2 should be serialized, but when it tries to reference rule1, it should detect circular ref + assertNotNull("Rule2 reference should exist", rule2Ref); + // rule2 itself should be fully serialized (not circular), but its condition's otherRule should be circular + Map<String, Object> conditionMap2 = (Map<String, Object>) rule2Ref.get("condition"); + assertNotNull("Rule2's condition should exist", conditionMap2); + Map<String, Object> paramValues2 = (Map<String, Object>) conditionMap2.get("parameterValues"); + Map<String, Object> rule1CircularRef = (Map<String, Object>) paramValues2.get("otherRule"); + assertNotNull("Circular reference to rule1 should be detected", rule1CircularRef); + assertEquals("Should contain circular reference marker", "circular", rule1CircularRef.get("$ref")); + } +} diff --git a/bom/pom.xml b/bom/pom.xml index 69cb5a051..17b0f3752 100644 --- a/bom/pom.xml +++ b/bom/pom.xml @@ -503,6 +503,26 @@ <artifactId>junit</artifactId> <version>${junit.version}</version> </dependency> + <dependency> + <groupId>org.junit.jupiter</groupId> + <artifactId>junit-jupiter</artifactId> + <version>${junit-jupiter.version}</version> + </dependency> + <dependency> + <groupId>org.mockito</groupId> + <artifactId>mockito-core</artifactId> + <version>${mockito.version}</version> + </dependency> + <dependency> + <groupId>org.mockito</groupId> + <artifactId>mockito-junit-jupiter</artifactId> + <version>${mockito.version}</version> + </dependency> + <dependency> + <groupId>org.awaitility</groupId> + <artifactId>awaitility</artifactId> + <version>${awaitility.version}</version> + </dependency> </dependencies> </dependencyManagement> diff --git a/itests/pom.xml b/itests/pom.xml index 98e98e9e5..d8b5d4a5e 100644 --- a/itests/pom.xml +++ b/itests/pom.xml @@ -32,6 +32,8 @@ <unomi.search.engine>elasticsearch</unomi.search.engine> <use.opensearch>false</use.opensearch> <docker.container.name>itests-opensearch</docker.container.name> + <!-- Skip caching for this module only --> + <maven.build.cache.skip>true</maven.build.cache.skip> </properties> <dependencyManagement> @@ -43,6 +45,14 @@ <type>pom</type> <scope>import</scope> </dependency> + <!-- Override awaitility version for integration tests to use older version compatible with hamcrest 1.3 --> + <!-- Awaitility 4.0.0+ requires hamcrest 2.1+, but we need hamcrest 1.3 for OSGi bundle compatibility --> + <!-- Using awaitility 3.1.6 which works with hamcrest 1.3 --> + <dependency> + <groupId>org.awaitility</groupId> + <artifactId>awaitility</artifactId> + <version>3.1.6</version> + </dependency> </dependencies> </dependencyManagement> @@ -53,6 +63,16 @@ <artifactId>common</artifactId> <version>${karaf.version}</version> <scope>test</scope> + <exclusions> + <exclusion> + <groupId>ch.qos.logback</groupId> + <artifactId>logback-classic</artifactId> + </exclusion> + <exclusion> + <groupId>ch.qos.logback</groupId> + <artifactId>logback-core</artifactId> + </exclusion> + </exclusions> </dependency> <!-- Define the Apache Karaf version to download and use for the test --> <!-- We use a released version here to avoid SNAPSHOT resolution --> @@ -105,7 +125,7 @@ <groupId>org.apache.servicemix.bundles</groupId> <artifactId>org.apache.servicemix.bundles.hamcrest</artifactId> <version>1.3_1</version> - <scope>runtime</scope> + <scope>test</scope> </dependency> <dependency> <groupId>org.apache.httpcomponents</groupId> diff --git a/pom.xml b/pom.xml index 25360797d..d4825e7ec 100644 --- a/pom.xml +++ b/pom.xml @@ -80,6 +80,7 @@ <snakeyaml.version>2.3</snakeyaml.version> <opencsv.version>3.10</opencsv.version> <log4j.version>2.19.0</log4j.version> + <slf4j.version>1.7.36</slf4j.version> <lucene.version>9.12.2</lucene.version> <failsafe.version>2.4.0</failsafe.version> <joda-time.version>2.12.7</joda-time.version> @@ -102,6 +103,9 @@ <httpclient-osgi.version>4.5.14</httpclient-osgi.version> <httpcore-osgi.version>4.4.16</httpcore-osgi.version> <junit.version>4.13.2</junit.version> + <junit-jupiter.version>5.8.2</junit-jupiter.version> + <mockito.version>4.5.1</mockito.version> + <awaitility.version>4.2.0</awaitility.version> <kafka-client.version>2.2.1</kafka-client.version> <st4.version>4.3.4</st4.version> <commons-email.version>1.6.0</commons-email.version> diff --git a/rest/src/main/java/org/apache/unomi/rest/models/RESTParameter.java b/rest/src/main/java/org/apache/unomi/rest/models/RESTParameter.java index bc7ab9acf..d6257c6f0 100644 --- a/rest/src/main/java/org/apache/unomi/rest/models/RESTParameter.java +++ b/rest/src/main/java/org/apache/unomi/rest/models/RESTParameter.java @@ -26,7 +26,7 @@ public class RESTParameter { private String id; private String type; private boolean multivalued = false; - private String defaultValue = null; + private Object defaultValue = null; public String getId() { return id; @@ -52,11 +52,11 @@ public class RESTParameter { this.multivalued = multivalued; } - public String getDefaultValue() { + public Object getDefaultValue() { return defaultValue; } - public void setDefaultValue(String defaultValue) { + public void setDefaultValue(Object defaultValue) { this.defaultValue = defaultValue; } diff --git a/services/src/main/java/org/apache/unomi/services/impl/rules/RulesServiceImpl.java b/services/src/main/java/org/apache/unomi/services/impl/rules/RulesServiceImpl.java index fe3fe8fb0..8b6e7063c 100644 --- a/services/src/main/java/org/apache/unomi/services/impl/rules/RulesServiceImpl.java +++ b/services/src/main/java/org/apache/unomi/services/impl/rules/RulesServiceImpl.java @@ -450,7 +450,15 @@ public class RulesServiceImpl implements RulesService, EventListenerService, Syn trackedCondition.getConditionType().getParameters().forEach(parameter -> { try { if (TRACKED_PARAMETER.equals(parameter.getId())) { - Arrays.stream(StringUtils.split(parameter.getDefaultValue(), ",")).forEach(trackedParameter -> { + // Parameter#getDefaultValue is Object; null must not call toString() (NPE) or be passed to split. + Object defaultValue = parameter.getDefaultValue(); + if (defaultValue == null) { + LOGGER.debug( + "Skipping tracked parameter mapping: parameter id={} has null defaultValue for condition type {}", + parameter.getId(), trackedCondition.getConditionType().getItemId()); + return; + } + Arrays.stream(StringUtils.split(defaultValue.toString(), ",")).forEach(trackedParameter -> { String[] param = StringUtils.split(StringUtils.trim(trackedParameter), ":"); trackedParameters.put(StringUtils.trim(param[1]), trackedCondition.getParameter(StringUtils.trim(param[0]))); });
